Initial commit

This commit is contained in:
Zdeněk Burda
2026-01-09 21:26:40 +01:00
parent e83aec6dca
commit 41e3ce6f25
404 changed files with 61250 additions and 28 deletions

View File

@@ -0,0 +1,192 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Input, Switch, Textarea } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { type NewsItem, type NewsPayload, type FormMode, type NewsTranslation } from "./adminNewsTypes";
const resolveLocale = (field: NewsTranslation | null | undefined, lang: string) => {
if (!field) return "";
if (typeof field === "string") return field;
return field[lang] ?? field["en"] ?? Object.values(field)[0] ?? "";
};
const formatForDateTimeLocal = (value: string | null | undefined) => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const pad = (num: number) => num.toString().padStart(2, "0");
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
type AdminNewsFormProps = {
mode: Exclude<FormMode, "none">;
editing: NewsItem | null;
onSubmit: (payload: NewsPayload) => Promise<void> | void;
onCancel: () => void;
submitting: boolean;
serverError: string | null;
};
export default function AdminNewsForm({
mode,
editing,
onSubmit,
onCancel,
submitting,
serverError,
}: AdminNewsFormProps) {
const { t } = useTranslation("common");
const [titleCs, setTitleCs] = useState("");
const [titleEn, setTitleEn] = useState("");
const [contentCs, setContentCs] = useState("");
const [contentEn, setContentEn] = useState("");
const [excerptCs, setExcerptCs] = useState("");
const [excerptEn, setExcerptEn] = useState("");
const [publishedAt, setPublishedAt] = useState("");
const [isPublished, setIsPublished] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
if (mode === "edit" && editing) {
setTitleCs(resolveLocale(editing.title, "cs"));
setTitleEn(resolveLocale(editing.title, "en"));
setExcerptCs(resolveLocale(editing.excerpt, "cs"));
setExcerptEn(resolveLocale(editing.excerpt, "en"));
setContentCs(resolveLocale(editing.content, "cs"));
setContentEn(resolveLocale(editing.content, "en"));
setPublishedAt(formatForDateTimeLocal(editing.published_at));
setIsPublished(!!editing.is_published);
setLocalError(null);
return;
}
setTitleCs("");
setTitleEn("");
setContentCs("");
setContentEn("");
setExcerptCs("");
setExcerptEn("");
setPublishedAt("");
setIsPublished(false);
setLocalError(null);
}, [mode, editing]);
const submitLabel = useMemo(() => {
if (mode === "edit") return t("admin_news_form_save") ?? "Uložit změny";
return t("admin_news_form_create") ?? "Vytvořit novinku";
}, [mode, t]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setLocalError(null);
const titlePayload: Record<string, string> = {};
if (titleCs.trim()) titlePayload.cs = titleCs.trim();
if (titleEn.trim()) titlePayload.en = titleEn.trim();
if (Object.keys(titlePayload).length === 0) {
setLocalError(t("admin_news_title_required") ?? "Vyplň nadpis alespoň v jednom jazyce.");
return;
}
const contentPayload: Record<string, string> = {};
if (contentCs.trim()) contentPayload.cs = contentCs.trim();
if (contentEn.trim()) contentPayload.en = contentEn.trim();
if (Object.keys(contentPayload).length === 0) {
setLocalError(t("admin_news_content_required") ?? "Vyplň obsah alespoň v jednom jazyce.");
return;
}
const excerptPayload: Record<string, string> = {};
if (excerptCs.trim()) excerptPayload.cs = excerptCs.trim();
if (excerptEn.trim()) excerptPayload.en = excerptEn.trim();
const payload: NewsPayload = {
title: titlePayload,
content: contentPayload,
is_published: isPublished,
};
if (Object.keys(excerptPayload).length > 0) payload.excerpt = excerptPayload;
if (publishedAt.trim()) payload.published_at = publishedAt.trim();
await onSubmit(payload);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<Input
label={t("admin_news_form_title_cs") ?? "Nadpis (cs)"}
value={titleCs}
onChange={(e) => setTitleCs(e.target.value)}
/>
<Input
label={t("admin_news_form_title_en") ?? "Title (en)"}
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Textarea
label={t("admin_news_form_excerpt_cs") ?? "Perex (cs)"}
value={excerptCs}
onChange={(e) => setExcerptCs(e.target.value)}
minRows={2}
/>
<Textarea
label={t("admin_news_form_excerpt_en") ?? "Excerpt (en)"}
value={excerptEn}
onChange={(e) => setExcerptEn(e.target.value)}
minRows={2}
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Textarea
label={t("admin_news_form_content_cs") ?? "Obsah (cs)"}
value={contentCs}
onChange={(e) => setContentCs(e.target.value)}
minRows={4}
/>
<Textarea
label={t("admin_news_form_content_en") ?? "Content (en)"}
value={contentEn}
onChange={(e) => setContentEn(e.target.value)}
minRows={4}
/>
</div>
<div className="grid gap-3 md:grid-cols-2 items-center">
<Input
type="datetime-local"
label={t("admin_news_form_published_from") ?? "Publikováno od"}
value={publishedAt}
onChange={(e) => setPublishedAt(e.target.value)}
/>
<div className="flex items-center gap-2">
<Switch isSelected={isPublished} onValueChange={setIsPublished}>
{t("admin_news_form_publish") ?? "Publikovat"}
</Switch>
</div>
</div>
{(localError || serverError) && (
<div className="text-sm text-red-600">{localError ?? serverError}</div>
)}
<div className="flex gap-3">
<Button type="submit" color="primary" isLoading={submitting}>
{submitLabel}
</Button>
<Button type="button" variant="light" onPress={onCancel}>
{t("admin_form_close") ?? "Zavřít formulář"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,112 @@
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@heroui/react";
import { useTranslation } from "react-i18next";
import { type NewsItem, type NewsTranslation } from "./adminNewsTypes";
const resolveLocale = (field: NewsTranslation | null | undefined, lang: string) => {
if (!field) return "";
if (typeof field === "string") return field;
return field[lang] ?? field["en"] ?? Object.values(field)[0] ?? "";
};
const formatDate = (value: string | null | undefined, lang: string) => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(lang, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
};
type AdminNewsTableProps = {
items: NewsItem[];
locale: string;
loading: boolean;
error: string | null;
onEdit: (item: NewsItem) => void;
};
export default function AdminNewsTable({
items,
locale,
loading,
error,
onEdit,
}: AdminNewsTableProps) {
const { t } = useTranslation("common");
const valuePlaceholder = t("value_na") ?? "—";
if (loading) {
return <div>{t("admin_news_loading") ?? "Načítám novinky…"}</div>;
}
if (error) {
return <div className="text-sm text-red-600">{error}</div>;
}
return (
<Table
aria-label={t("admin_news_table_aria") ?? "Admin news table"}
selectionMode="none"
>
<TableHeader>
<TableColumn>{t("admin_news_title_cs") ?? "Název (cs)"}</TableColumn>
<TableColumn>{t("admin_news_title_en") ?? "Title (en)"}</TableColumn>
<TableColumn>{t("admin_news_published_at") ?? "Publikováno"}</TableColumn>
<TableColumn>{t("admin_news_published_flag") ?? "Zveřejněno"}</TableColumn>
<TableColumn></TableColumn>
</TableHeader>
<TableBody items={items}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{resolveLocale(item.title, "cs")}</TableCell>
<TableCell>{resolveLocale(item.title, "en")}</TableCell>
<TableCell>
{(() => {
if (!item.published_at) return valuePlaceholder;
const date = new Date(item.published_at);
if (Number.isNaN(date.getTime())) return valuePlaceholder;
const isFuture = date.getTime() > Date.now();
const className = isFuture ? "italic" : "font-semibold";
return (
<span className={className}>
{formatDate(item.published_at, locale)}
</span>
);
})()}
</TableCell>
<TableCell>
{item.is_published ? t("yes") ?? "Ano" : t("no") ?? "Ne"}
</TableCell>
<TableCell>
<button
type="button"
onClick={() => onEdit(item)}
className="inline-flex items-center p-1 rounded hover:bg-default-100 text-default-500 hover:text-default-700"
aria-label={t("admin_news_edit_aria") ?? "Upravit novinku"}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path d="M13.586 3.586a2 2 0 0 1 2.828 2.828l-8.25 8.25a2 2 0 0 1-.878.518l-3.122.89a.75.75 0 0 1-.926-.926l.89-3.122a2 2 0 0 1 .518-.878l8.25-8.25Z" />
<path d="M12.475 4.697 4.75 12.422l-.69 2.422 2.422-.69 7.725-7.725-1.732-1.732Z" />
</svg>
</button>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,21 @@
export type NewsTranslation = string | Record<string, string>;
export type NewsItem = {
id: number;
title: NewsTranslation;
excerpt: NewsTranslation | null;
content: NewsTranslation;
slug: string;
published_at: string | null;
is_published: boolean;
};
export type NewsPayload = {
title: Record<string, string>;
content: Record<string, string>;
excerpt?: Record<string, string>;
published_at?: string;
is_published: boolean;
};
export type FormMode = "none" | "create" | "edit";