RustyCMS: file-based headless CMS — API, Admin UI (content, types, assets), Docker/Caddy, image transform; only demo type and demo content in version control
Made-with: Cursor
This commit is contained in:
233
admin-ui/src/app/content/[collection]/page.tsx
Normal file
233
admin-ui/src/app/content/[collection]/page.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { fetchContentList, fetchLocales } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchBar } from "@/components/SearchBar";
|
||||
import { PaginationLinks } from "@/components/PaginationLinks";
|
||||
import { ContentLocaleSwitcher } from "@/components/ContentLocaleSwitcher";
|
||||
import { Breadcrumbs } from "@/components/Breadcrumbs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const PER_PAGE = 20;
|
||||
|
||||
export default function ContentListPage() {
|
||||
const t = useTranslations("ContentListPage");
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const collection = typeof params.collection === "string" ? params.collection : "";
|
||||
|
||||
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 { data: localesData } = useQuery({
|
||||
queryKey: ["locales"],
|
||||
queryFn: fetchLocales,
|
||||
});
|
||||
const locales = localesData?.locales ?? [];
|
||||
const defaultLocale = localesData?.default ?? null;
|
||||
|
||||
const listParams = {
|
||||
_page: page,
|
||||
_per_page: PER_PAGE,
|
||||
...(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="rounded bg-amber-50 p-4 text-amber-800">
|
||||
Missing collection name.
|
||||
</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]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: tBread("content"), href: "/" },
|
||||
{ label: collection },
|
||||
]}
|
||||
/>
|
||||
<div className="mb-6 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>
|
||||
|
||||
{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 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 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="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>
|
||||
)}
|
||||
|
||||
{!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]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<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 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 editHref = `/content/${collection}/${encodeURIComponent(slug)}${localeQ ? `?${localeQ}` : ""}`;
|
||||
return (
|
||||
<TableRow key={slug}>
|
||||
<TableCell className="font-mono text-sm">{slug}</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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<PaginationLinks
|
||||
collection={collection}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
locale={locale ?? undefined}
|
||||
sort={sort}
|
||||
order={order}
|
||||
q={q ?? undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user