Fix articles list crash by moving interactive card to client component
Local Build and Deploy / deploy (push) Successful in 1m4s
Local Build and Deploy / deploy (push) Successful in 1m4s
Extract the article card (thumbnail onError fallback, delete confirmation) into a Client Component so the admin articles list server page no longer passes event handlers to server-rendered elements.
This commit is contained in:
1 parent
6215074331
commit
243f8007d9
2 files changed
+88
-61
No files matched your search
@@ -1,8 +1,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { deleteArticle } from "@/actions/admin-articles";
|
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
|
import { ArticleCard } from "@/components/admin/article-card";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
@@ -94,67 +94,15 @@ export default async function AdminArticles({
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
|
||||||
{articles.map((a) => (
|
{articles.map((a) => (
|
||||||
<div
|
<ArticleCard
|
||||||
key={String(a.id)}
|
key={String(a.id)}
|
||||||
className="admin-card overflow-hidden flex flex-col"
|
id={String(a.id)}
|
||||||
>
|
title={a.title}
|
||||||
<div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
|
slug={a.slug}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
image={a.image}
|
||||||
<img
|
createdAt={a.createdAt ? a.createdAt.toISOString() : null}
|
||||||
src={a.image || ""}
|
author={authorMap.get(a.userId ?? -1)}
|
||||||
alt={a.title}
|
/>
|
||||||
className="w-full h-full object-cover block"
|
|
||||||
onError={(
|
|
||||||
e: React.SyntheticEvent<HTMLImageElement>,
|
|
||||||
) => {
|
|
||||||
e.currentTarget.style.visibility = "hidden";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 flex flex-col flex-1">
|
|
||||||
<Link
|
|
||||||
href={`/admin/articles/${a.id}`}
|
|
||||||
className="font-semibold text-[var(--admin-text)] hover:text-[var(--admin-accent)] line-clamp-2"
|
|
||||||
style={{ textDecoration: "none" }}
|
|
||||||
>
|
|
||||||
{a.title}
|
|
||||||
</Link>
|
|
||||||
<p className="text-xs text-[var(--admin-text-muted)] mt-1 mb-3 flex items-center gap-2 flex-wrap">
|
|
||||||
<span>{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}</span>
|
|
||||||
{authorMap.get(a.userId ?? -1) ? (
|
|
||||||
<>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{authorMap.get(a.userId ?? -1)}</span>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2 mt-auto">
|
|
||||||
<Link
|
|
||||||
href={`/admin/articles/${a.id}`}
|
|
||||||
className="btn btn-primary flex-1 text-center"
|
|
||||||
>
|
|
||||||
{t("edit")}
|
|
||||||
</Link>
|
|
||||||
<form action={deleteArticle} className="m-0">
|
|
||||||
<input type="hidden" name="id" value={String(a.id)} />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-danger"
|
|
||||||
onClick={(e) => {
|
|
||||||
if (
|
|
||||||
!window.confirm(
|
|
||||||
t("confirmDelete", { title: a.title }),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("delete")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { deleteArticle } from "@/actions/admin-articles";
|
||||||
|
|
||||||
|
export function ArticleCard({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
image,
|
||||||
|
createdAt,
|
||||||
|
author,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
image: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
author?: string;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("pages.admin.articles");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-card overflow-hidden flex flex-col">
|
||||||
|
<div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={image || ""}
|
||||||
|
alt={title}
|
||||||
|
className="w-full h-full object-cover block"
|
||||||
|
onError={(e) => {
|
||||||
|
e.currentTarget.style.visibility = "hidden";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 flex flex-col flex-1">
|
||||||
|
<Link
|
||||||
|
href={`/admin/articles/${id}`}
|
||||||
|
className="font-semibold text-[var(--admin-text)] hover:text-[var(--admin-accent)] line-clamp-2"
|
||||||
|
style={{ textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs text-[var(--admin-text-muted)] mt-1 mb-3 flex items-center gap-2 flex-wrap">
|
||||||
|
<span>{createdAt ? createdAt.slice(0, 10) : "—"}</span>
|
||||||
|
{author ? (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{author}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 mt-auto">
|
||||||
|
<Link
|
||||||
|
href={`/admin/articles/${id}`}
|
||||||
|
className="btn btn-primary flex-1 text-center"
|
||||||
|
>
|
||||||
|
{t("edit")}
|
||||||
|
</Link>
|
||||||
|
<form action={deleteArticle} className="m-0">
|
||||||
|
<input type="hidden" name="id" value={id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!window.confirm(t("confirmDelete", { title }))) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("delete")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user