feat(cms): improve catalog, editorial recovery and operations
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user