diff --git a/drizzle/migrations/0025_article_publication.sql b/drizzle/migrations/0025_article_publication.sql new file mode 100644 index 0000000000..53f15d1e22 --- /dev/null +++ b/drizzle/migrations/0025_article_publication.sql @@ -0,0 +1,7 @@ +-- Existing articles remain published. Preserve any publication settings already present. +ALTER TABLE website_articles + ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'published', + ADD COLUMN IF NOT EXISTS publish_at TIMESTAMP NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS published_at TIMESTAMP NULL DEFAULT NULL; +CREATE INDEX IF NOT EXISTS idx_website_articles_publication + ON website_articles (status, publish_at, created_at); diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts index 7f3b50b272..cb62e8cac2 100644 --- a/scripts/jobs-worker.ts +++ b/scripts/jobs-worker.ts @@ -12,6 +12,7 @@ import { logger } from "../src/lib/logger"; import { redis } from "../src/lib/redis"; import { emulatorOffline, healthDegraded } from "../src/lib/services/alert"; import { runCatalogExport } from "../src/lib/services/catalog-git-export"; +import { invalidateNewsCache } from "../src/lib/services/news-cache"; import { rcon } from "../src/lib/services/rcon"; function captureWorkerError(err: unknown, context: string): void { @@ -246,12 +247,10 @@ async function publishScheduledArticles(): Promise { lte(WebsiteArticles.publishAt, now), ), ); - const info = result as unknown as { - affectedRows?: number; - rowsAffected?: number; - }; - const published = info.affectedRows ?? info.rowsAffected ?? 0; + const [info] = result; + const published = info.affectedRows ?? 0; if (published > 0) { + await invalidateNewsCache(); logger.info(`Published ${published} scheduled article(s)`, { module: "jobs", }); diff --git a/src/actions/admin-articles.test.ts b/src/actions/admin-articles.test.ts new file mode 100644 index 0000000000..ca6d8a4fe6 --- /dev/null +++ b/src/actions/admin-articles.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + rows: [] as unknown[][], + insert: vi.fn(), + update: vi.fn(), + invalidate: vi.fn(), + log: vi.fn(() => "news-error-1"), + notify: vi.fn(), +})); +vi.mock("@/lib/admin/guard", () => ({ + requirePermission: async () => ({ id: 1, username: "staff" }), +})); +vi.mock("@/lib/permissions", () => ({ PERMS: { NEWS_EDIT: "news.edit" } })); +vi.mock("@/lib/services/webhook", () => ({ notify: state.notify })); +vi.mock("@/lib/services/news-cache", () => ({ + invalidateNewsCache: state.invalidate, +})); +vi.mock("@/lib/logger", () => ({ logger: { error: state.log } })); +vi.mock("next-intl/server", () => ({ + getTranslations: async () => (key: string, args?: { reference: string }) => + key + (args?.reference ?? ""), +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("next/navigation", () => ({ + redirect: (url: string) => { + throw Error(`redirect ${url}`); + }, +})); +vi.mock("@/lib/db", async () => ({ + ...(await import("@/db/schema")), + db: { + select: () => ({ + from: () => ({ + where: () => ({ limit: async () => state.rows.shift() ?? [] }), + }), + }), + insert: () => ({ values: state.insert }), + update: () => ({ + set: (value: unknown) => { + state.update(value); + return { where: async () => [{ affectedRows: 1 }] }; + }, + }), + }, +})); + +import { createArticle, updateArticle } from "./admin-articles"; + +function form(extra: Record = {}) { + const f = new FormData(); + for (const [k, v] of Object.entries({ + title: "Test news", + slug: "test-news", + shortStory: "Summary", + fullStory: "Body", + status: "published", + ...extra, + })) + f.set(k, v); + return f; +} +describe("news saves", () => { + beforeEach(() => { + vi.clearAllMocks(); + state.rows = []; + state.insert.mockResolvedValue([{ insertId: 1 }]); + }); + it("preserves a recoverable error and logs its reference", async () => { + state.insert.mockRejectedValueOnce(Error("Unknown column status")); + const result = await createArticle(form()); + expect(result).toEqual({ + ok: false, + error: "saveFailedReferencenews-error-1", + }); + expect(state.invalidate).not.toHaveBeenCalled(); + expect(state.log).toHaveBeenCalled(); + }); + it("saves drafts without publication notifications", async () => { + expect((await createArticle(form({ status: "draft" }))).ok).toBe(true); + expect(state.insert).toHaveBeenCalledWith( + expect.objectContaining({ + status: "draft", + publishAt: null, + publishedAt: null, + }), + ); + expect(state.notify).not.toHaveBeenCalled(); + expect(state.invalidate).toHaveBeenCalledOnce(); + }); + it("preserves the slug and clears old scheduling for immediate publication", async () => { + state.rows = [[{ slug: "test-news", status: "draft", publishedAt: null }]]; + expect( + ( + await updateArticle( + form({ id: "1", publishAt: "2099-01-01T00:00:00Z" }), + ) + ).ok, + ).toBe(true); + expect(state.update).toHaveBeenCalledWith( + expect.objectContaining({ + slug: "test-news", + publishAt: null, + status: "published", + }), + ); + expect(state.invalidate).toHaveBeenCalledOnce(); + }); + it("rejects invalid scheduled dates before writing", async () => { + expect( + (await createArticle(form({ status: "scheduled", publishAt: "invalid" }))) + .ok, + ).toBe(false); + expect(state.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index b9cbf1d11f..0b7f6c4c16 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -1,9 +1,15 @@ "use server"; -import { eq } from "drizzle-orm"; +import { and, eq, ne } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { requirePermission } from "@/lib/admin/guard"; +import { + ArticleInputError, + type ArticleSaveResult, + readArticleInput, +} from "@/lib/article-input"; import { db, WebsiteArticleComments, @@ -12,152 +18,139 @@ import { } from "@/lib/db"; import { formPositiveBigInt } from "@/lib/form-data"; import { slugify } from "@/lib/format"; +import { logger } from "@/lib/logger"; import { PERMS } from "@/lib/permissions"; +import { invalidateNewsCache } from "@/lib/services/news-cache"; import { notify } from "@/lib/services/webhook"; -const ARTICLE_STATUSES = ["published", "scheduled", "draft"] as const; -type ArticleStatus = (typeof ARTICLE_STATUSES)[number]; - -function parseArticleStatus(raw: unknown): ArticleStatus { - const value = String(raw ?? "published").trim(); - return (ARTICLE_STATUSES as readonly string[]).includes(value) - ? (value as ArticleStatus) - : "published"; -} - -function parsePublishAt(raw: unknown): Date | null { - const value = String(raw ?? "").trim(); - if (!value) return null; - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date; -} - -async function uniqueSlug(title: string): Promise { +async function uniqueSlug(title: string, excludeId?: bigint): Promise { const base = slugify(title); let slug = base; - let n = 2; - for (;;) { + for (let n = 2; ; n++) { const [existing] = await db .select({ id: WebsiteArticles.id }) .from(WebsiteArticles) - .where(eq(WebsiteArticles.slug, slug)) + .where( + and( + eq(WebsiteArticles.slug, slug), + excludeId ? ne(WebsiteArticles.id, excludeId) : undefined, + ), + ) .limit(1); if (!existing) return slug; - slug = `${base}-${n++}`; + const suffix = `-${n}`; + slug = base.slice(0, 255 - suffix.length) + suffix; } } - -export async function createArticle(formData: FormData): Promise { +async function saveFailure( + error: unknown, + operation: string, +): Promise { + const t = await getTranslations("pages.admin.articles.form"); + if (error instanceof ArticleInputError) + return { ok: false, error: t(error.code) }; + const reference = logger.error("News save failed", { + module: "news", + operation, + error, + }); + return { ok: false, error: t("saveFailedReference", { reference }) }; +} +async function refreshNews() { + await invalidateNewsCache(); + revalidatePath("/admin/articles"); + revalidatePath("/news", "layout"); + revalidatePath("/me"); + revalidatePath("/"); +} +export async function createArticle( + formData: FormData, +): Promise { const staff = await requirePermission(PERMS.NEWS_EDIT); - const title = String(formData.get("title") ?? "") - .normalize("NFC") - .trim(); - const shortStory = String(formData.get("shortStory") ?? "") - .normalize("NFC") - .trim(); - const fullStory = String(formData.get("fullStory") ?? "") - .normalize("NFC") - .trim(); - const image = String(formData.get("image") ?? "") - .normalize("NFC") - .trim(); - const rawSlug = String(formData.get("slug") ?? "").trim(); - const status = parseArticleStatus(formData.get("status")); - const rawPublishAt = String(formData.get("publishAt") ?? "").trim(); - if (!title) return; - if (status === "scheduled" && !parsePublishAt(rawPublishAt)) { - redirect( - "/admin/articles/new?error=Scheduled articles need a valid publish date.", - ); - } - + let input: ReturnType; + let slug: string; try { + input = readArticleInput(formData); + const { rawSlug, ...fields } = input; + slug = await uniqueSlug(rawSlug || fields.title); const now = new Date(); - const publishAt = parsePublishAt(rawPublishAt); - const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title); await db.insert(WebsiteArticles).values({ + ...fields, slug, - title: title.slice(0, 255), - shortStory: shortStory.slice(0, 255), - fullStory, - image: image.slice(0, 255), userId: staff.id, createdAt: now, updatedAt: now, - status, - publishAt, - publishedAt: status === "published" ? now : null, + publishedAt: fields.status === "published" ? now : null, }); - + } catch (error) { + return saveFailure(error, "create"); + } + // Auxiliary services must not turn a committed insert into an apparent failure. + if (input.status === "published") notify({ action: "news_publish", actor: staff.username, - target: title, + target: input.title, details: slug, }); - } catch { - // Database error — re-render unchanged with error. - redirect( - "/admin/articles/new?error=Database error while creating article. Please try again.", - ); - } - redirect("/admin/articles"); + await refreshNews(); + return { ok: true, data: { redirectTo: "/admin/articles" } }; } - -export async function updateArticle(formData: FormData): Promise { - await requirePermission(PERMS.NEWS_EDIT); +export async function updateArticle( + formData: FormData, +): Promise { + const staff = await requirePermission(PERMS.NEWS_EDIT); + const t = await getTranslations("pages.admin.articles.form"); const id = formPositiveBigInt(formData, "id"); - if (!id) redirect("/admin/articles?error=Missing article id"); - const rawSlug = String(formData.get("slug") ?? "").trim(); - const status = parseArticleStatus(formData.get("status")); - const rawPublishAt = String(formData.get("publishAt") ?? "").trim(); - if (status === "scheduled" && !parsePublishAt(rawPublishAt)) { - redirect( - "/admin/articles?error=Scheduled articles need a valid publish date.", - ); - } + if (!id) return { ok: false, error: t("articleNotFound") }; + let input: ReturnType; + let becamePublished = false; + let slug: string; try { - const publishAt = parsePublishAt(rawPublishAt); + input = readArticleInput(formData); const [existing] = await db .select({ + slug: WebsiteArticles.slug, status: WebsiteArticles.status, publishedAt: WebsiteArticles.publishedAt, }) .from(WebsiteArticles) .where(eq(WebsiteArticles.id, id)) .limit(1); - const publishedAt = - status === "published" ? (existing?.publishedAt ?? new Date()) : null; + if (!existing) return { ok: false, error: t("articleNotFound") }; + const { rawSlug, ...fields } = input; + const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug; + slug = + requestedSlug === existing.slug + ? existing.slug + : await uniqueSlug(requestedSlug, id); + becamePublished = + fields.status === "published" && existing.status !== "published"; await db .update(WebsiteArticles) .set({ - title: String(formData.get("title") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255), - ...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}), - shortStory: String(formData.get("shortStory") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255), - fullStory: String(formData.get("fullStory") ?? "") - .normalize("NFC") - .trim(), - image: String(formData.get("image") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255), - status, - publishAt, - publishedAt, + ...fields, + slug, + publishedAt: + fields.status === "published" + ? (existing.publishedAt ?? new Date()) + : null, updatedAt: new Date(), }) .where(eq(WebsiteArticles.id, id)); - } catch { - redirect("/admin/articles?error=Update failed"); + } catch (error) { + return saveFailure(error, "update"); } + if (becamePublished) + notify({ + action: "news_publish", + actor: staff.username, + target: input.title, + details: slug, + }); + await refreshNews(); revalidatePath(`/admin/articles/${id}`); - redirect("/admin/articles"); + return { ok: true, data: { redirectTo: "/admin/articles" } }; } export async function deleteArticle(formData: FormData): Promise { @@ -185,8 +178,10 @@ export async function deleteArticle(formData: FormData): Promise { actor: staff.username, target: article?.title ?? String(id), }); - } catch { + } catch (error) { + logger.error("News deletion failed", { module: "news", error }); redirect("/admin/articles?error=Delete failed"); } + await refreshNews(); redirect("/admin/articles"); } diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index b4e3f445ea..e05cf99173 100644 --- a/src/app/(site)/me/page.tsx +++ b/src/app/(site)/me/page.tsx @@ -99,13 +99,7 @@ export default async function MePage({ ); const canClaim = referralTotal >= needed; const remaining = Math.max(0, needed - referralTotal); - const onlineFriends = [ - ...new Map( - friends - .filter((friend) => friend.online === "1") - .map((friend) => [friend.id, friend]), - ).values(), - ]; + const onlineFriends = friends.filter((friend) => friend.online === "1"); const date = (value: number) => value ? format.dateTime(new Date(value * 1000), { dateStyle: "medium" }) diff --git a/src/app/(site)/news/[...slug]/page.tsx b/src/app/(site)/news/[...slug]/page.tsx index 11136a4492..2dddeeac2b 100644 --- a/src/app/(site)/news/[...slug]/page.tsx +++ b/src/app/(site)/news/[...slug]/page.tsx @@ -8,8 +8,8 @@ import { toggleReaction } from "@/actions/article-reactions"; import Link from "@/components/link"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { SanitizedHtml } from "@/components/shared/sanitized-html"; +import { articleSlug } from "@/lib/article-input"; import { auth } from "@/lib/auth"; -import { cachedQuery } from "@/lib/cached-db"; import { db, User, @@ -20,6 +20,7 @@ import { import { excerpt } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; import { sanitize } from "@/lib/sanitize"; +import { cacheNews } from "@/lib/services/news-cache"; type SearchParams = Promise<{ comment?: string; @@ -46,10 +47,10 @@ function feedbackStyle(tone: "success" | "error"): CSSProperties { export async function generateMetadata({ params, }: { - params: Promise<{ slug: string }>; + params: Promise<{ slug: string[] }>; }): Promise { const appUrl = process.env.APP_URL ?? "http://localhost:3000"; - const { slug } = await params; + const slug = articleSlug((await params).slug); const [article] = await db .select({ title: WebsiteArticles.title, @@ -95,44 +96,40 @@ export default async function ArticlePage({ params, searchParams, }: { - params: Promise<{ slug: string }>; + params: Promise<{ slug: string[] }>; searchParams: SearchParams; }) { - const { slug } = await params; + const slug = articleSlug((await params).slug); const { comment, reaction, error } = await searchParams; const t = await getTranslations("pages.article"); - const article = await cachedQuery( - `article:slug:${slug}`, - async () => { - const [row] = await db - .select({ - id: WebsiteArticles.id, - slug: WebsiteArticles.slug, - title: WebsiteArticles.title, - shortStory: WebsiteArticles.shortStory, - fullStory: WebsiteArticles.fullStory, - image: WebsiteArticles.image, - createdAt: WebsiteArticles.createdAt, - updatedAt: WebsiteArticles.updatedAt, - }) - .from(WebsiteArticles) - .where( - and( - eq(WebsiteArticles.slug, slug), - eq(WebsiteArticles.status, "published"), - or( - sql`${WebsiteArticles.publishAt} IS NULL`, - sql`${WebsiteArticles.publishAt} <= NOW()`, - ), + const article = await cacheNews(`article:slug:${slug}`, 60_000, async () => { + const [row] = await db + .select({ + id: WebsiteArticles.id, + slug: WebsiteArticles.slug, + title: WebsiteArticles.title, + shortStory: WebsiteArticles.shortStory, + fullStory: WebsiteArticles.fullStory, + image: WebsiteArticles.image, + createdAt: WebsiteArticles.createdAt, + updatedAt: WebsiteArticles.updatedAt, + }) + .from(WebsiteArticles) + .where( + and( + eq(WebsiteArticles.slug, slug), + eq(WebsiteArticles.status, "published"), + or( + sql`${WebsiteArticles.publishAt} IS NULL`, + sql`${WebsiteArticles.publishAt} <= NOW()`, ), - ) - .limit(1) - .catch(() => []); - return row ?? null; - }, - 60, - ); + ), + ) + .limit(1) + .catch(() => []); + return row ?? null; + }); if (!article) notFound(); const articleId = article.id; diff --git a/src/app/admin/articles/[id]/page.tsx b/src/app/admin/articles/[id]/page.tsx index 985734d11e..2abd7831a9 100644 --- a/src/app/admin/articles/[id]/page.tsx +++ b/src/app/admin/articles/[id]/page.tsx @@ -31,6 +31,8 @@ export default async function EditArticle({ title: WebsiteArticles.title, shortStory: WebsiteArticles.shortStory, fullStory: WebsiteArticles.fullStory, + status: WebsiteArticles.status, + publishAt: WebsiteArticles.publishAt, image: WebsiteArticles.image, }) .from(WebsiteArticles) @@ -63,7 +65,7 @@ export default async function EditArticle({ action={async (fd) => { "use server"; fd.set("id", String(article.id)); - await updateArticle(fd); + return updateArticle(fd); }} defaultValues={{ title: article.title, @@ -71,6 +73,8 @@ export default async function EditArticle({ image: article.image, shortStory: article.shortStory, fullStory: article.fullStory, + status: article.status, + publishAt: article.publishAt?.toISOString(), }} />
diff --git a/src/app/api/articles/[slug]/route.ts b/src/app/api/articles/[slug]/route.ts index 8b4cb02c1f..5219dffc6d 100644 --- a/src/app/api/articles/[slug]/route.ts +++ b/src/app/api/articles/[slug]/route.ts @@ -1,7 +1,9 @@ import { and, eq, or, sql } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { db, WebsiteArticles } from "@/lib/db"; -import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; +import { logger } from "@/lib/logger"; +import { apiCacheKey, cacheSafe } from "@/lib/redis-cache"; +import { cacheNews } from "@/lib/services/news-cache"; /** * GET /api/articles/:slug — single website_article by slug, including the @@ -14,9 +16,9 @@ export async function GET( const { slug } = await params; try { - const data = await redisCache( + const data = await cacheNews( apiCacheKey(`articles:${slug}`), - 60, + 60_000, async () => { const [article] = await db .select({ @@ -55,7 +57,8 @@ export async function GET( } return apiJson(data); - } catch { + } catch (error) { + logger.error("Public news query failed", { module: "news", error }); // DB unavailable — treat as not found rather than a 500. return apiJson({ error: "Article not found" }, { status: 200 }); } diff --git a/src/app/api/articles/route.ts b/src/app/api/articles/route.ts index 6d63f5f131..c704b980a4 100644 --- a/src/app/api/articles/route.ts +++ b/src/app/api/articles/route.ts @@ -1,7 +1,9 @@ import { and, count, desc, eq, or, sql } from "drizzle-orm"; import { apiJson, pagination } from "@/lib/api"; import { db, WebsiteArticles } from "@/lib/db"; -import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; +import { logger } from "@/lib/logger"; +import { apiCacheKey, cacheSafe } from "@/lib/redis-cache"; +import { cacheNews } from "@/lib/services/news-cache"; /** * GET /api/articles — paginated list of website_articles, newest first. @@ -13,9 +15,9 @@ export async function GET(req: Request) { const { page, perPage, skip, take } = pagination(sp); try { - const data = await redisCache( + const data = await cacheNews( apiCacheKey(`articles:${page}:${perPage}`), - 60, + 60_000, async () => { const publishedFilter = and( eq(WebsiteArticles.status, "published"), @@ -60,7 +62,8 @@ export async function GET(req: Request) { ); return apiJson(data); - } catch { + } catch (error) { + logger.error("Public news query failed", { module: "news", error }); // DB unavailable — return an empty payload instead of a 500. return apiJson( { data: [], meta: { page, perPage, total: 0, lastPage: 1 } }, diff --git a/src/app/api/home/route.ts b/src/app/api/home/route.ts index 1e4319ceab..ec2ea91da6 100644 --- a/src/app/api/home/route.ts +++ b/src/app/api/home/route.ts @@ -3,7 +3,9 @@ import { env } from "@/env"; import { apiJson } from "@/lib/api"; import { db, User, WebsiteArticles } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; -import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; +import { logger } from "@/lib/logger"; +import { apiCacheKey, cacheSafe } from "@/lib/redis-cache"; +import { cacheNews } from "@/lib/services/news-cache"; /** * GET /api/home — combined landing payload: the latest 4 website_articles and @@ -12,7 +14,7 @@ import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; */ export async function GET(_req: Request) { try { - const data = await redisCache(apiCacheKey("home"), 15, async () => { + const data = await cacheNews(apiCacheKey("home"), 15_000, async () => { const [articles, onlineRows, hotelName] = await Promise.all([ db .select({ @@ -45,7 +47,8 @@ export async function GET(_req: Request) { }); }); return apiJson(data); - } catch { + } catch (error) { + logger.error("Public news query failed", { module: "news", error }); return apiJson( { articles: [], online: 0, hotelName: env.HOTEL_NAME }, { status: 200 }, diff --git a/src/components/admin/article-form.tsx b/src/components/admin/article-form.tsx index 5321e1a5d7..949b977acf 100644 --- a/src/components/admin/article-form.tsx +++ b/src/components/admin/article-form.tsx @@ -1,8 +1,10 @@ "use client"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useMemo, useRef, useState, useTransition } from "react"; +import { useEffect, useMemo, useRef, useState, useTransition } from "react"; import { useUnsavedChanges } from "@/hooks/use-unsaved-changes"; +import type { ArticleSaveResult } from "@/lib/article-input"; import { slugify } from "@/lib/format"; import { ArticlePreview, type ArticlePreviewData } from "./article-preview"; import { MediaPicker } from "./media-picker"; @@ -12,7 +14,7 @@ export function ArticleForm({ action, defaultValues, }: { - action: (formData: FormData) => Promise; + action: (formData: FormData) => Promise; edit?: boolean; defaultValues?: { title?: string; @@ -28,7 +30,8 @@ export function ArticleForm({ const tAction = useTranslations("pages.admin.actions"); const [dirty, setDirty] = useState(false); const [pending, startTransition] = useTransition(); - const [saveError, setSaveError] = useState(false); + const [saveError, setSaveError] = useState(null); + const router = useRouter(); const saving = useRef(false); const editVersion = useRef(0); function markDirty() { @@ -42,6 +45,14 @@ export function ArticleForm({ const [image, setImage] = useState(defaultValues?.image ?? ""); const [imageError, setImageError] = useState(false); const [status, setStatus] = useState(defaultValues?.status ?? "published"); + const [publishAt, setPublishAt] = useState(""); + useEffect(() => { + if (!defaultValues?.publishAt) return; + const date = new Date(defaultValues.publishAt); + if (Number.isNaN(date.getTime())) return; + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); + setPublishAt(local.toISOString().slice(0, 16)); + }, [defaultValues?.publishAt]); const formRef = useRef(null); const [preview, setPreview] = useState(null); @@ -55,13 +66,24 @@ export function ArticleForm({ event.preventDefault(); if (saving.current) return; const data = new FormData(event.currentTarget); + if (status === "scheduled" && publishAt) { + const date = new Date(publishAt); + if (!Number.isNaN(date.getTime())) + data.set("publishAt", date.toISOString()); + } const submittedVersion = editVersion.current; saving.current = true; - setSaveError(false); + setSaveError(null); startTransition(async () => { try { - await action(data); + const result = await action(data); + if (result && !result.ok) { + setSaveError(result.error); + return; + } if (editVersion.current === submittedVersion) setDirty(false); + if (result?.ok && result.data?.redirectTo) + router.push(result.data.redirectTo); } catch (error) { if ( error && @@ -72,7 +94,7 @@ export function ArticleForm({ setDirty(false); throw error; } - setSaveError(true); + setSaveError(tAction("saveError")); } finally { saving.current = false; } @@ -84,7 +106,7 @@ export function ArticleForm({
{saveError && (

- {tAction("saveError")} + {saveError}

)}
@@ -94,6 +116,7 @@ export function ArticleForm({ setTitle(e.target.value)} placeholder={t("titlePlaceholder")} @@ -108,6 +131,7 @@ export function ArticleForm({ { setSlug(e.target.value); @@ -127,6 +151,7 @@ export function ArticleForm({ { setImage(e.target.value); @@ -148,7 +173,7 @@ export function ArticleForm({ // eslint-disable-next-line @next/next/no-img-element preview setImageError(true)} className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover" style={{ maxHeight: 160 }} @@ -156,17 +181,18 @@ export function ArticleForm({ ) : null} {imageError ? (

- Image not found — enter a valid URL or browse. + {t("imageNotFound")}

) : null}