351 lines
13 KiB
TypeScript
351 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { useParams, useSearchParams } from "next/navigation";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Icon } from "@iconify/react";
|
|
import { useTranslations } from "next-intl";
|
|
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,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
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"],
|
|
queryFn: fetchLocales,
|
|
});
|
|
const locales = localesData?.locales ?? [];
|
|
const defaultLocale = localesData?.default ?? null;
|
|
|
|
const listParams = {
|
|
_page: page,
|
|
_per_page: perPage,
|
|
_status: "all" as const,
|
|
...(sort ? { _sort: sort, _order: order } : {}),
|
|
...(q?.trim() ? { _q: q.trim() } : {}),
|
|
...(locale ? { _locale: locale } : {}),
|
|
};
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ["content", collection, listParams],
|
|
queryFn: () => fetchContentList(collection, listParams),
|
|
enabled: !!collection,
|
|
});
|
|
|
|
if (!collection) {
|
|
return (
|
|
<div className="p-4 sm:p-5 md:p-6">
|
|
<div className="rounded bg-amber-50 p-4 text-amber-800">Missing collection name.</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const items = data?.items ?? [];
|
|
const total = data?.total ?? 0;
|
|
const totalPages = data?.total_pages ?? 1;
|
|
const localeQ = locale ? `_locale=${locale}` : "";
|
|
const baseQuery = new URLSearchParams();
|
|
if (locale) baseQuery.set("_locale", locale);
|
|
if (q?.trim()) baseQuery.set("_q", q.trim());
|
|
const sortQuery = (field: string, order: "asc" | "desc") => {
|
|
const p = new URLSearchParams(baseQuery);
|
|
p.set("_sort", field);
|
|
p.set("_order", order);
|
|
return p.toString();
|
|
};
|
|
const isSortSlug = sort === "_slug" || !sort;
|
|
const nextSlugOrder = isSortSlug && order === "asc" ? "desc" : "asc";
|
|
|
|
const tBread = useTranslations("Breadcrumbs");
|
|
|
|
useEffect(() => {
|
|
document.title = collection ? `${collection} — RustyCMS Admin` : "RustyCMS Admin";
|
|
return () => {
|
|
document.title = "RustyCMS Admin";
|
|
};
|
|
}, [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 className="p-4 sm:p-5 md:p-6">
|
|
<Breadcrumbs
|
|
items={[
|
|
{ label: tBread("content"), href: "/" },
|
|
{ label: collection },
|
|
]}
|
|
/>
|
|
<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>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<ContentLocaleSwitcher
|
|
locales={locales}
|
|
defaultLocale={defaultLocale}
|
|
currentLocale={locale}
|
|
/>
|
|
<SearchBar
|
|
placeholder={t("searchPlaceholder")}
|
|
paramName="_q"
|
|
/>
|
|
<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>
|
|
|
|
{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" />
|
|
<div className="rounded-lg border border-gray-200">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>_slug</TableHead>
|
|
<TableHead className="w-24">{t("colStatus")}</TableHead>
|
|
<TableHead className="w-28 text-right">{t("colActions")}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{[1, 2, 3, 4, 5].map((i) => (
|
|
<TableRow key={i}>
|
|
<TableCell><Skeleton className="h-5 w-48" /></TableCell>
|
|
<TableCell><Skeleton className="h-5 w-16" /></TableCell>
|
|
<TableCell className="text-right"><Skeleton className="ml-auto h-8 w-16" /></TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<p className="text-red-600" role="alert">
|
|
{error instanceof Error ? error.message : String(error)}
|
|
</p>
|
|
)}
|
|
|
|
{!isLoading && !error && items.length === 0 && (
|
|
<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-fixed">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<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"
|
|
title={t("sortBy", { field: "_slug" })}
|
|
>
|
|
_slug
|
|
{isSortSlug && (
|
|
<Icon
|
|
icon={order === "asc" ? "mdi:chevron-up" : "mdi:chevron-down"}
|
|
className="size-4"
|
|
aria-label={order === "asc" ? t("sortAsc") : t("sortDesc")}
|
|
/>
|
|
)}
|
|
</Link>
|
|
</TableHead>
|
|
<TableHead className="w-24">{t("colStatus")}</TableHead>
|
|
<TableHead className="w-28 text-right">{t("colActions")}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{items.map((entry: Record<string, unknown>) => {
|
|
const slug = entry._slug as string | undefined;
|
|
if (slug == null) return null;
|
|
const isDraft = entry._status === "draft";
|
|
const editHref = `/content/${collection}/${encodeURIComponent(slug)}${localeQ ? `?${localeQ}` : ""}`;
|
|
return (
|
|
<TableRow key={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>
|
|
</TableCell>
|
|
<TableCell className="w-24">
|
|
<span
|
|
className={
|
|
isDraft
|
|
? "rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-800"
|
|
: "rounded bg-success-100 px-1.5 py-0.5 text-xs font-medium text-success-800"
|
|
}
|
|
>
|
|
{isDraft ? t("draft") : t("published")}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<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>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
<PaginationLinks
|
|
collection={collection}
|
|
page={page}
|
|
totalPages={totalPages}
|
|
total={total}
|
|
locale={locale ?? undefined}
|
|
sort={sort}
|
|
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>
|
|
);
|
|
}
|