Enhance documentation and admin UI: Add detailed implementation guidelines in CLAUDE.md, introduce a referrer index in README.md, and update admin UI translations for improved user experience. Update package dependencies for better functionality and performance.

This commit is contained in:
Peter Meier
2026-03-13 10:55:33 +01:00
parent 7754d800f5
commit 606455c59b
42 changed files with 3814 additions and 421 deletions

View File

@@ -1,17 +1,29 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Icon } from "@iconify/react";
import { useTranslations } from "next-intl";
import { fetchContentList, fetchLocales } from "@/lib/api";
import { toast } from "sonner";
import { fetchContentList, fetchSchema, fetchLocales, getPerPage, deleteEntry } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { CodeBlock } from "@/components/CodeBlock";
import { SearchBar } from "@/components/SearchBar";
import { PaginationLinks } from "@/components/PaginationLinks";
import { ContentLocaleSwitcher } from "@/components/ContentLocaleSwitcher";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Table,
TableBody,
@@ -21,20 +33,31 @@ import {
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
const PER_PAGE = 20;
import { CollapsibleSection } from "@/components/ui/collapsible";
import { TypeDependencyGraph } from "@/components/TypeDependencyGraph";
export default function ContentListPage() {
const t = useTranslations("ContentListPage");
const tSchema = useTranslations("SchemaAndPreviewBar");
const params = useParams();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const collection = typeof params.collection === "string" ? params.collection : "";
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const page = Math.max(1, parseInt(searchParams.get("_page") ?? "1", 10) || 1);
const sort = searchParams.get("_sort") ?? undefined;
const order = (searchParams.get("_order") ?? "asc") as "asc" | "desc";
const q = searchParams.get("_q") ?? undefined;
const locale = searchParams.get("_locale") ?? undefined;
const perPage = getPerPage();
const { data: schema } = useQuery({
queryKey: ["schema", collection],
queryFn: () => fetchSchema(collection),
enabled: !!collection,
});
const { data: localesData } = useQuery({
queryKey: ["locales"],
@@ -45,7 +68,7 @@ export default function ContentListPage() {
const listParams = {
_page: page,
_per_page: PER_PAGE,
_per_page: perPage,
_status: "all" as const,
...(sort ? { _sort: sort, _order: order } : {}),
...(q?.trim() ? { _q: q.trim() } : {}),
@@ -91,6 +114,21 @@ export default function ContentListPage() {
};
}, [collection]);
const handleDoDelete = async () => {
if (!pendingDelete) return;
setDeleting(true);
try {
await deleteEntry(collection, pendingDelete, locale ? { _locale: locale } : {});
await queryClient.invalidateQueries({ queryKey: ["content", collection] });
setPendingDelete(null);
toast.success(t("deleted"));
} catch (e) {
toast.error(e instanceof Error ? e.message : t("errorDeleting"));
} finally {
setDeleting(false);
}
};
return (
<div>
<Breadcrumbs
@@ -99,7 +137,7 @@ export default function ContentListPage() {
{ label: collection },
]}
/>
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<h1 className="text-xl font-semibold text-gray-900 sm:text-2xl truncate">
{collection}
</h1>
@@ -122,6 +160,39 @@ export default function ContentListPage() {
</div>
</div>
{schema && (
<CollapsibleSection
title={
<span className="flex items-center gap-2">
<Icon icon="mdi:script-text-outline" className="size-5 text-gray-500" aria-hidden />
{tSchema("sectionSchema")}
</span>
}
defaultOpen={false}
className="mb-4"
contentClassName="max-h-[60vh] overflow-auto"
>
<CodeBlock
code={JSON.stringify(schema, null, 2)}
language="json"
copyLabel={tSchema("copyCode")}
/>
</CollapsibleSection>
)}
<CollapsibleSection
title={
<span className="flex items-center gap-2">
<Icon icon="mdi:graph-outline" className="size-5 text-gray-500" aria-hidden />
{t("typeDependencies")}
</span>
}
defaultOpen={false}
className="mb-4"
>
<TypeDependencyGraph currentCollection={collection} />
</CollapsibleSection>
{isLoading && (
<div className="space-y-3">
<Skeleton className="h-10 w-full max-w-md" />
@@ -130,7 +201,7 @@ export default function ContentListPage() {
<TableHeader>
<TableRow>
<TableHead>_slug</TableHead>
<TableHead className="w-24 text-right">{t("colActions")}</TableHead>
<TableHead className="w-28 text-right">{t("colActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -152,31 +223,24 @@ export default function ContentListPage() {
)}
{!isLoading && !error && items.length === 0 && (
<div
className="mx-auto max-w-md rounded-xl border border-gray-200 bg-gray-50/80 px-8 text-center shadow-sm"
style={{ marginTop: "1.25rem", marginBottom: "1.25rem", paddingTop: "2rem", paddingBottom: "2.5rem" }}
>
<p className="text-base text-gray-600" style={{ marginBottom: "1.5rem" }}>
{t("noEntriesCreate")}
</p>
<div className="flex justify-center" style={{ marginBottom: "1rem" }}>
<Button asChild className="min-h-[44px] sm:min-h-0">
<Link href={`/content/${collection}/new${localeQ ? `?${localeQ}` : ""}`}>
<Icon icon="mdi:plus" className="size-5" aria-hidden />
{t("newEntry")}
</Link>
</Button>
</div>
<div className="py-8 text-center">
<p className="text-muted-foreground mb-4">{t("noEntriesCreate")}</p>
<Button asChild className="min-h-[44px] sm:min-h-0">
<Link href={`/content/${collection}/new${localeQ ? `?${localeQ}` : ""}`}>
<Icon icon="mdi:plus" className="size-5" aria-hidden />
{t("newEntry")}
</Link>
</Button>
</div>
)}
{!isLoading && !error && items.length > 0 && (
<>
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0 rounded-lg border border-gray-200">
<Table className="min-w-[280px]">
<Table className="min-w-[280px] table-fixed">
<TableHeader>
<TableRow>
<TableHead>
<TableHead className="w-64 max-w-[16rem]">
<Link
href={`/content/${collection}?${sortQuery("_slug", nextSlugOrder)}`}
className="inline-flex items-center gap-1 font-medium hover:underline"
@@ -192,7 +256,7 @@ export default function ContentListPage() {
)}
</Link>
</TableHead>
<TableHead className="w-24 text-right">{t("colActions")}</TableHead>
<TableHead className="w-28 text-right">{t("colActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -203,8 +267,14 @@ export default function ContentListPage() {
const editHref = `/content/${collection}/${encodeURIComponent(slug)}${localeQ ? `?${localeQ}` : ""}`;
return (
<TableRow key={slug}>
<TableCell className="font-mono text-sm">
{slug}
<TableCell className="w-64 max-w-[16rem] font-mono text-sm">
<Link
href={editHref}
className="block truncate text-accent-700 hover:underline hover:text-accent-900"
title={slug}
>
{slug}
</Link>
{isDraft && (
<span className="ml-2 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-800">
{t("draft")}
@@ -212,12 +282,22 @@ export default function ContentListPage() {
)}
</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" asChild className="min-h-[44px] sm:min-h-0">
<Link href={editHref}>
<Icon icon="mdi:pencil-outline" className="size-4" aria-hidden />
{t("edit")}
</Link>
</Button>
<div className="flex items-center justify-end gap-2">
<Button variant="outline" size="icon-sm" asChild className="min-h-[44px] w-11 sm:min-h-0">
<Link href={editHref} aria-label={t("edit")}>
<Icon icon="mdi:pencil-outline" className="size-4" aria-hidden />
</Link>
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => setPendingDelete(slug)}
aria-label={t("delete")}
className="min-h-[44px] w-11 border-red-200 text-red-700 hover:bg-red-50 hover:text-red-800 sm:min-h-0"
>
<Icon icon="mdi:delete-outline" className="size-4" aria-hidden />
</Button>
</div>
</TableCell>
</TableRow>
);
@@ -235,6 +315,25 @@ export default function ContentListPage() {
order={order}
q={q ?? undefined}
/>
<AlertDialog open={!!pendingDelete} onOpenChange={(o) => !o && setPendingDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("confirmDelete", { slug: pendingDelete ?? "" })}</AlertDialogTitle>
<AlertDialogDescription>{t("confirmDeleteDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDoDelete}
disabled={deleting}
className="bg-destructive text-white hover:bg-destructive/90"
>
{deleting ? t("deleting") : t("yesDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>