feat(cms): improve catalog, editorial recovery and operations

This commit is contained in:
2026-09-09 19:36:15 +02:00
parent 2fead134e6
commit c389c3893d
122 changed files with 8137 additions and 703 deletions
+3
View File
@@ -6,6 +6,7 @@ import { deleteArticle, updateArticle } from "@/actions/admin-articles";
import { ArticleForm } from "@/components/admin/article-form";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { articleEditToken } from "@/lib/article-edit-token";
import { db, WebsiteArticles } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
@@ -62,6 +63,8 @@ export default async function EditArticle({
</p>
) : null}
<ArticleForm
articleKey={String(article.id)}
baseToken={articleEditToken(article)}
action={async (fd) => {
"use server";
fd.set("id", String(article.id));
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { useTranslations } from "next-intl";
import { useFormStatus } from "react-dom";
import { Button } from "@/components/ui/button";
export function ArticleListSubmit() {
const { pending } = useFormStatus();
const t = useTranslations("pages.admin.articles");
return (
<Button type="submit" disabled={pending} aria-busy={pending}>
{pending ? t("listLoading") : t("listApply")}
</Button>
);
}
+141 -77
View File
@@ -1,107 +1,171 @@
import { desc, inArray } from "drizzle-orm";
import { Newspaper } from "lucide-react";
import Form from "next/form";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { ArticleCard } from "@/components/admin/article-card";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, User, WebsiteArticles } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { Input } from "@/components/ui/input";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { loadArticleList } from "@/lib/services/article-list";
import { ArticleListSubmit } from "./list-submit";
export default async function AdminArticles({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
searchParams: Promise<{
error?: string;
search?: string;
status?: string;
page?: string;
}>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) {
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank))
redirect("/admin");
}
const { error } = await searchParams;
const params = await searchParams;
const t = await getTranslations("pages.admin.articles");
const articles = await db
.select({
id: WebsiteArticles.id,
title: WebsiteArticles.title,
slug: WebsiteArticles.slug,
image: WebsiteArticles.image,
createdAt: WebsiteArticles.createdAt,
userId: WebsiteArticles.userId,
})
.from(WebsiteArticles)
.orderBy(desc(WebsiteArticles.createdAt))
.limit(50)
.catch(() => []);
const authorMap = new Map<number, string>();
const authorIds = articles
.map((a) => a.userId)
.filter((id): id is number => Boolean(id));
if (authorIds.length > 0) {
const authors = await db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, authorIds))
.catch(() => []);
for (const u of authors) authorMap.set(u.id, u.username);
let result: Awaited<ReturnType<typeof loadArticleList>> | null = null;
try {
result = await loadArticleList({
status: params.status,
search: params.search,
page: Number(params.page ?? 1),
});
} catch {
/* Render a retry state instead of an empty article list. */
}
const latest = articles[0]?.createdAt
? formatDate(articles[0].createdAt, "date")
: "—";
const status =
result?.status ??
(["draft", "published", "scheduled"].includes(params.status ?? "")
? params.status
: "all");
const search = result?.search ?? (params.search ?? "").slice(0, 191);
const href = (page: number) =>
`/admin/articles?${new URLSearchParams({ status: status ?? "all", search, page: String(page) })}`;
return (
<AdminPageShell
icon={Newspaper}
title={t("title")}
subtitle={t("subtitle")}
actions={
<Button variant="default" asChild>
<Link href="/admin/articles/new">{t("newArticle")}</Link>
</Button>
canAccess(permissions, PERMS.NEWS_EDIT, session.user.rank) ? (
<Button asChild>
<Link href="/admin/articles/new">{t("newArticle")}</Link>
</Button>
) : undefined
}
>
{error ? (
<p className="text-xs text-[var(--admin-error)] mb-4">Error: {error}</p>
{params.error ? (
<p role="alert" className="text-sm text-[var(--admin-error)] mb-4">
{t("listActionError")}
</p>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("articlesShown")}
value={articles.length}
icon="📰"
/>
<StatusCard
label={t("latest")}
value={latest}
hint={t("mostRecentPost")}
icon="🕒"
/>
</div>
<section className="mt-2">
<h2 className="admin-section-title">{t("recentArticles")}</h2>
{articles.length === 0 ? (
<div className="admin-empty">{t("noArticles")}</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
{articles.map((a) => (
<ArticleCard
key={String(a.id)}
id={String(a.id)}
title={a.title}
image={a.image}
createdAt={a.createdAt ? a.createdAt.toISOString() : null}
author={authorMap.get(a.userId ?? -1)}
/>
<Form
action="/admin/articles"
className="admin-card mb-5 flex flex-wrap items-end gap-3"
key={`${status}:${search}`}
>
<label htmlFor="article-status" className="grid gap-1 text-sm">
<span>{t("listStatus")}</span>
<select
id="article-status"
name="status"
defaultValue={status}
className="h-9 rounded-md border bg-[var(--admin-surface)] px-3"
>
{["all", "draft", "published", "scheduled"].map((value) => (
<option value={value} key={value}>
{t(`listStatus_${value}`)}
</option>
))}
</div>
)}
</section>
</select>
</label>
<label
htmlFor="article-search"
className="grid flex-1 min-w-0 gap-1 text-sm"
>
<span>{t("listSearch")}</span>
<Input
id="article-search"
name="search"
defaultValue={search}
maxLength={191}
/>
</label>
<ArticleListSubmit />
<Button variant="outline" asChild>
<Link href="/admin/articles">{t("listReset")}</Link>
</Button>
</Form>
{!result ? (
<div className="admin-card space-y-3" role="alert">
<p>{t("listUnavailable")}</p>
<Link
className="btn btn-outline"
href={href(Number(params.page) || 1)}
>
{t("listRetry")}
</Link>
</div>
) : (
<>
<p className="mb-4 text-sm text-[var(--admin-text-muted)]">
{t("listTotal", { count: result.total })}
</p>
{result.rows.length === 0 ? (
<div className="admin-empty">
{search || status !== "all"
? t("listNoMatches")
: t("noArticles")}
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,260px),1fr))] gap-4">
{result.rows.map((article) => (
<div key={String(article.id)} className="min-w-0 space-y-2">
<span className="text-xs text-[var(--admin-text-muted)]">
{["draft", "published", "scheduled"].includes(
article.status,
)
? t(`listStatus_${article.status}`)
: article.status}
</span>
<ArticleCard
id={String(article.id)}
title={article.title}
image={article.image}
createdAt={article.createdAt?.toISOString() ?? null}
author={article.author ?? undefined}
/>
</div>
))}
</div>
)}
<nav
aria-label={t("listPagination")}
className="mt-5 flex flex-wrap items-center justify-between gap-3"
>
{result.page > 1 ? (
<Link className="btn btn-outline" href={href(result.page - 1)}>
{t("listPrevious")}
</Link>
) : (
<span />
)}
<span className="text-sm">
{t("listPage", { page: result.page, total: result.lastPage })}
</span>
{result.page < result.lastPage ? (
<Link className="btn btn-outline" href={href(result.page + 1)}>
{t("listNext")}
</Link>
) : (
<span />
)}
</nav>
</>
)}
</AdminPageShell>
);
}