Polish admin articles: card grid, thumbnails, author, slug field
Local Build and Deploy / deploy (push) Successful in 1m14s
Local Build and Deploy / deploy (push) Successful in 1m14s
Replace the plain articles table with a responsive card grid showing thumbnails, title, date and author. Add a slug field to the article form with live auto-suggestion, i18n-driven labels, a larger image preview and delete confirmation. Persist a user-provided slug (falls back to an auto-generated one) on create and update.
This commit is contained in:
1 parent
a07d438987
commit
6215074331
4 files changed
+182
-85
No files matched your search
@@ -36,13 +36,14 @@ export async function createArticle(formData: FormData): Promise<void> {
|
|||||||
const image = String(formData.get("image") ?? "")
|
const image = String(formData.get("image") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
|
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.websiteArticles.create({
|
await prisma.websiteArticles.create({
|
||||||
data: {
|
data: {
|
||||||
slug: await uniqueSlug(title),
|
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
|
||||||
title: title.slice(0, 255),
|
title: title.slice(0, 255),
|
||||||
shortStory: shortStory.slice(0, 255),
|
shortStory: shortStory.slice(0, 255),
|
||||||
fullStory,
|
fullStory,
|
||||||
@@ -64,6 +65,7 @@ export async function createArticle(formData: FormData): Promise<void> {
|
|||||||
export async function updateArticle(formData: FormData): Promise<void> {
|
export async function updateArticle(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.NEWS_EDIT);
|
await requirePermission(PERMS.NEWS_EDIT);
|
||||||
const id = BigInt(String(formData.get("id")));
|
const id = BigInt(String(formData.get("id")));
|
||||||
|
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||||
try {
|
try {
|
||||||
await prisma.websiteArticles.update({
|
await prisma.websiteArticles.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -72,6 +74,9 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255),
|
.slice(0, 255),
|
||||||
|
...(rawSlug
|
||||||
|
? { slug: await uniqueSlug(rawSlug) }
|
||||||
|
: {}),
|
||||||
shortStory: String(formData.get("shortStory") ?? "")
|
shortStory: String(formData.get("shortStory") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export default async function EditArticle({
|
|||||||
}}
|
}}
|
||||||
defaultValues={{
|
defaultValues={{
|
||||||
title: article.title,
|
title: article.title,
|
||||||
|
slug: article.slug,
|
||||||
image: article.image,
|
image: article.image,
|
||||||
shortStory: article.shortStory,
|
shortStory: article.shortStory,
|
||||||
fullStory: article.fullStory,
|
fullStory: article.fullStory,
|
||||||
|
|||||||
@@ -23,18 +23,45 @@ export default async function AdminArticles({
|
|||||||
const articles = await prisma.websiteArticles
|
const articles = await prisma.websiteArticles
|
||||||
.findMany({
|
.findMany({
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
select: { id: true, title: true, slug: true, createdAt: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
image: true,
|
||||||
|
createdAt: true,
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
take: 50,
|
take: 50,
|
||||||
})
|
})
|
||||||
.catch(() => []);
|
.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 prisma.user
|
||||||
|
.findMany({
|
||||||
|
where: { id: { in: authorIds } },
|
||||||
|
select: { id: true, username: true },
|
||||||
|
})
|
||||||
|
.catch(() => []);
|
||||||
|
for (const u of authors) authorMap.set(u.id, u.username);
|
||||||
|
}
|
||||||
|
|
||||||
const latest = articles[0]?.createdAt
|
const latest = articles[0]?.createdAt
|
||||||
? articles[0].createdAt.toISOString().slice(0, 10)
|
? articles[0].createdAt.toISOString().slice(0, 10)
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<div className="flex justify-end mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="m-0 text-2xl font-extrabold text-[var(--admin-text)]">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="m-0 text-sm text-[var(--admin-text-muted)] mt-1">
|
||||||
|
{t("subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Link href="/admin/articles/new" className="btn btn-primary">
|
<Link href="/admin/articles/new" className="btn btn-primary">
|
||||||
+ {t("newArticle")}
|
+ {t("newArticle")}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -65,47 +92,71 @@ export default async function AdminArticles({
|
|||||||
{articles.length === 0 ? (
|
{articles.length === 0 ? (
|
||||||
<div className="admin-empty">{t("noArticles")}</div>
|
<div className="admin-empty">{t("noArticles")}</div>
|
||||||
) : (
|
) : (
|
||||||
<table>
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
|
||||||
<thead>
|
{articles.map((a) => (
|
||||||
<tr>
|
<div
|
||||||
<th>{t("colTitle")}</th>
|
key={String(a.id)}
|
||||||
<th>{t("colSlug")}</th>
|
className="admin-card overflow-hidden flex flex-col"
|
||||||
<th>{t("colCreated")}</th>
|
>
|
||||||
<th>{t("colActions")}</th>
|
<div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
|
||||||
</tr>
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
</thead>
|
<img
|
||||||
<tbody>
|
src={a.image || ""}
|
||||||
{articles.map((a) => (
|
alt={a.title}
|
||||||
<tr key={String(a.id)}>
|
className="w-full h-full object-cover block"
|
||||||
<td>
|
onError={(
|
||||||
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
|
e: React.SyntheticEvent<HTMLImageElement>,
|
||||||
</td>
|
) => {
|
||||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
e.currentTarget.style.visibility = "hidden";
|
||||||
<code>{a.slug}</code>
|
}}
|
||||||
</td>
|
/>
|
||||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
</div>
|
||||||
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
|
<div className="p-3 flex flex-col flex-1">
|
||||||
</td>
|
<Link
|
||||||
<td>
|
href={`/admin/articles/${a.id}`}
|
||||||
<div className="flex gap-2">
|
className="font-semibold text-[var(--admin-text)] hover:text-[var(--admin-accent)] line-clamp-2"
|
||||||
<Link
|
style={{ textDecoration: "none" }}
|
||||||
href={`/admin/articles/${a.id}`}
|
>
|
||||||
className="btn btn-primary"
|
{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("edit")}
|
{t("delete")}
|
||||||
</Link>
|
</button>
|
||||||
<form action={deleteArticle} className="m-0">
|
</form>
|
||||||
<input type="hidden" name="id" value={String(a.id)} />
|
</div>
|
||||||
<button type="submit" className="btn btn-danger">
|
</div>
|
||||||
{t("delete")}
|
</div>
|
||||||
</button>
|
))}
|
||||||
</form>
|
</div>
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,40 +1,73 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { MediaPicker } from "./media-picker";
|
import { MediaPicker } from "./media-picker";
|
||||||
import { RichText } from "./rich-text";
|
import { RichText } from "./rich-text";
|
||||||
|
import { slugify } from "@/lib/format";
|
||||||
|
|
||||||
export function ArticleForm({
|
export function ArticleForm({
|
||||||
action,
|
action,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
}: {
|
}: {
|
||||||
action: (formData: FormData) => Promise<void>;
|
action: (formData: FormData) => Promise<void>;
|
||||||
|
edit?: boolean;
|
||||||
defaultValues?: {
|
defaultValues?: {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
slug?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
shortStory?: string;
|
shortStory?: string;
|
||||||
fullStory?: string;
|
fullStory?: string;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("pages.admin.articles.form");
|
||||||
|
const [title, setTitle] = useState(defaultValues?.title ?? "");
|
||||||
|
const [slug, setSlug] = useState(defaultValues?.slug ?? "");
|
||||||
|
const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug));
|
||||||
const [image, setImage] = useState(defaultValues?.image ?? "");
|
const [image, setImage] = useState(defaultValues?.image ?? "");
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form action={action} className="admin-card" style={{ display: "grid", gap: "1rem" }}>
|
||||||
action={action}
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
className="card"
|
<label className="block">
|
||||||
style={{ display: "grid", gap: "0.6rem" }}
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
>
|
{t("title")}
|
||||||
<input
|
</span>
|
||||||
name="title"
|
<input
|
||||||
defaultValue={defaultValues?.title ?? ""}
|
name="title"
|
||||||
placeholder="Title"
|
value={title}
|
||||||
required
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
/>
|
placeholder={t("titlePlaceholder")}
|
||||||
<div>
|
required
|
||||||
<div
|
className="input input-bordered mt-1 w-full"
|
||||||
style={{ display: "flex", gap: "0.5rem", alignItems: "flex-start" }}
|
/>
|
||||||
>
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
|
{t("slug")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name="slug"
|
||||||
|
value={slugTouched ? slug : suggestedSlug}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSlug(e.target.value);
|
||||||
|
setSlugTouched(true);
|
||||||
|
}}
|
||||||
|
placeholder={t("slugPlaceholder")}
|
||||||
|
className="input input-bordered mt-1 w-full"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="block">
|
||||||
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
|
{t("image")}
|
||||||
|
</span>
|
||||||
|
<div className="mt-1 flex gap-2 items-start">
|
||||||
<input
|
<input
|
||||||
name="image"
|
name="image"
|
||||||
value={image}
|
value={image}
|
||||||
@@ -42,8 +75,8 @@ export function ArticleForm({
|
|||||||
setImage(e.target.value);
|
setImage(e.target.value);
|
||||||
setImageError(false);
|
setImageError(false);
|
||||||
}}
|
}}
|
||||||
placeholder="Image URL"
|
placeholder={t("imagePlaceholder")}
|
||||||
style={{ flex: 1 }}
|
className="input input-bordered flex-1"
|
||||||
/>
|
/>
|
||||||
<MediaPicker
|
<MediaPicker
|
||||||
onSelect={(url) => {
|
onSelect={(url) => {
|
||||||
@@ -59,37 +92,44 @@ export function ArticleForm({
|
|||||||
src={image}
|
src={image}
|
||||||
alt="preview"
|
alt="preview"
|
||||||
onError={() => setImageError(true)}
|
onError={() => setImageError(true)}
|
||||||
style={{
|
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
|
||||||
marginTop: "0.4rem",
|
style={{ maxHeight: 160 }}
|
||||||
maxHeight: 120,
|
|
||||||
borderRadius: 6,
|
|
||||||
objectFit: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{imageError ? (
|
{imageError ? (
|
||||||
<p
|
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
|
||||||
className="muted"
|
|
||||||
style={{ margin: "0.3rem 0 0", fontSize: "0.78rem" }}
|
|
||||||
>
|
|
||||||
Image not found — enter a valid URL or browse.
|
Image not found — enter a valid URL or browse.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
|
||||||
name="shortStory"
|
<label className="block">
|
||||||
defaultValue={defaultValues?.shortStory ?? ""}
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
placeholder="Short story (summary)"
|
{t("body")}
|
||||||
rows={2}
|
</span>
|
||||||
/>
|
<textarea
|
||||||
<RichText
|
name="shortStory"
|
||||||
name="fullStory"
|
defaultValue={defaultValues?.shortStory ?? ""}
|
||||||
defaultValue={defaultValues?.fullStory ?? ""}
|
placeholder={t("bodyPlaceholder")}
|
||||||
placeholder="Full story"
|
rows={2}
|
||||||
rows={10}
|
className="input input-bordered mt-1 w-full resize-y"
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary">
|
</label>
|
||||||
{defaultValues ? "Save" : "Create"}
|
|
||||||
|
<div className="block">
|
||||||
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
|
{t("body")}
|
||||||
|
</span>
|
||||||
|
<RichText
|
||||||
|
name="fullStory"
|
||||||
|
defaultValue={defaultValues?.fullStory ?? ""}
|
||||||
|
placeholder={t("bodyPlaceholder")}
|
||||||
|
rows={12}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary justify-self-start">
|
||||||
|
{t("save")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user