From dbf817c25511b3038b7abe81e4577d3518fd3f19 Mon Sep 17 00:00:00 2001 From: Citali Date: Thu, 12 Feb 2026 20:08:08 +0100 Subject: [PATCH] feat(content): add announcements and public news flows --- TODO.md | 14 +- apps/admin/src/app/announcements/page.tsx | 423 ++++++++++++++++++ apps/admin/src/app/news/page.tsx | 276 ++++++++++++ apps/admin/src/components/admin-shell.tsx | 2 + apps/admin/src/lib/access.test.ts | 8 + apps/admin/src/lib/access.ts | 14 + apps/web/src/app/[locale]/layout.tsx | 3 +- .../web/src/app/[locale]/news/[slug]/page.tsx | 30 ++ apps/web/src/app/[locale]/news/page.tsx | 33 ++ apps/web/src/app/[locale]/page.tsx | 3 +- .../src/components/public-announcements.tsx | 39 ++ packages/content/src/announcements.ts | 32 ++ packages/content/src/index.ts | 1 + .../migration.sql | 23 + packages/db/prisma/schema.prisma | 18 + packages/db/prisma/seed.ts | 17 + packages/db/src/announcements.test.ts | 54 +++ packages/db/src/announcements.ts | 74 +++ packages/db/src/index.ts | 9 + packages/db/src/posts.ts | 6 + 20 files changed, 1071 insertions(+), 8 deletions(-) create mode 100644 apps/admin/src/app/announcements/page.tsx create mode 100644 apps/admin/src/app/news/page.tsx create mode 100644 apps/web/src/app/[locale]/news/[slug]/page.tsx create mode 100644 apps/web/src/app/[locale]/news/page.tsx create mode 100644 apps/web/src/components/public-announcements.tsx create mode 100644 packages/content/src/announcements.ts create mode 100644 packages/db/prisma/migrations/20260212213000_announcements_news/migration.sql create mode 100644 packages/db/src/announcements.test.ts create mode 100644 packages/db/src/announcements.ts diff --git a/TODO.md b/TODO.md index 9c9b828..e52b93e 100644 --- a/TODO.md +++ b/TODO.md @@ -126,7 +126,7 @@ This file is the single source of truth for roadmap and delivery progress. page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds) - [~] [P1] `todo/mvp1-commissions-customers`: commission request intake + admin CRUD + kanban + customer entity/linking -- [ ] [P1] `todo/mvp1-announcements-news`: +- [~] [P1] `todo/mvp1-announcements-news`: announcement management/rendering + news/blog CRUD and public rendering - [~] [P1] `todo/mvp1-public-rendering-integration`: public rendering for pages/navigation/media/portfolio/announcements and commissioning entrypoints @@ -161,8 +161,8 @@ This file is the single source of truth for roadmap and delivery progress. - [~] [P1] Customer-to-commission linkage and reuse workflow (no re-entry for recurring customers) - [~] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done) - [ ] [P1] Header banner management (message, CTA, active window) -- [ ] [P1] Announcements management (prominent site notices with schedule, priority, and audience targeting) -- [ ] [P2] News/blog editorial workflow (draft/review/publish, authoring metadata) +- [~] [P1] Announcements management (prominent site notices with schedule, priority, and audience targeting) +- [~] [P2] News/blog editorial workflow (draft/review/publish, authoring metadata) ### Public App @@ -179,9 +179,9 @@ This file is the single source of truth for roadmap and delivery progress. ### News / Blog (Secondary Track) -- [ ] [P1] News/blog content type (editorial content for artist updates and process posts) -- [ ] [P1] Admin list/editor for news posts -- [ ] [P1] Public news index + detail pages +- [~] [P1] News/blog content type (editorial content for artist updates and process posts) +- [~] [P1] Admin list/editor for news posts +- [~] [P1] Public news index + detail pages - [ ] [P2] Tag/category and basic archive support ### Testing @@ -277,6 +277,8 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-12] MVP1 pages/navigation baseline started: `Page`, `NavigationMenu`, and `NavigationItem` models plus admin CRUD routes (`/pages`, `/pages/:id`, `/navigation`). - [2026-02-12] Public app now renders CMS-managed navigation (header) and CMS-managed pages by slug (including homepage when `home` page exists). - [2026-02-12] Commissions/customer baseline added: admin `/commissions` now supports customer creation, commission intake, status transitions, and a basic kanban board. +- [2026-02-12] Announcements/news baseline added: admin `/announcements` + `/news` management screens and public announcement rendering slots (`global_top`, `homepage`). +- [2026-02-12] Public news routes now exist at `/news` and `/news/:slug` (detail restricted to published posts). ## How We Use This File diff --git a/apps/admin/src/app/announcements/page.tsx b/apps/admin/src/app/announcements/page.tsx new file mode 100644 index 0000000..95129fd --- /dev/null +++ b/apps/admin/src/app/announcements/page.tsx @@ -0,0 +1,423 @@ +import { + createAnnouncement, + deleteAnnouncement, + listAnnouncements, + updateAnnouncement, +} from "@cms/db" +import { Button } from "@cms/ui/button" +import { revalidatePath } from "next/cache" +import { redirect } from "next/navigation" + +import { AdminShell } from "@/components/admin-shell" +import { requirePermissionForRoute } from "@/lib/route-guards" + +export const dynamic = "force-dynamic" + +type SearchParamsInput = Record + +function readFirstValue(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null + } + + return value ?? null +} + +function readInputString(formData: FormData, field: string): string { + const value = formData.get(field) + return typeof value === "string" ? value.trim() : "" +} + +function readNullableString(formData: FormData, field: string): string | null { + const value = readInputString(formData, field) + return value.length > 0 ? value : null +} + +function readNullableDate(formData: FormData, field: string): Date | null { + const value = readInputString(formData, field) + + if (!value) { + return null + } + + const parsed = new Date(value) + + if (Number.isNaN(parsed.getTime())) { + return null + } + + return parsed +} + +function readInt(formData: FormData, field: string, fallback = 100): number { + const value = readInputString(formData, field) + + if (!value) { + return fallback + } + + const parsed = Number.parseInt(value, 10) + + if (!Number.isFinite(parsed)) { + return fallback + } + + return parsed +} + +function redirectWithState(params: { notice?: string; error?: string }) { + const query = new URLSearchParams() + + if (params.notice) { + query.set("notice", params.notice) + } + + if (params.error) { + query.set("error", params.error) + } + + const value = query.toString() + redirect(value ? `/announcements?${value}` : "/announcements") +} + +async function createAnnouncementAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/announcements", + permission: "banner:write", + scope: "global", + }) + + try { + await createAnnouncement({ + title: readInputString(formData, "title"), + message: readInputString(formData, "message"), + placement: readInputString(formData, "placement"), + priority: readInt(formData, "priority", 100), + ctaLabel: readNullableString(formData, "ctaLabel"), + ctaHref: readNullableString(formData, "ctaHref"), + startsAt: readNullableDate(formData, "startsAt"), + endsAt: readNullableDate(formData, "endsAt"), + isVisible: readInputString(formData, "isVisible") === "true", + }) + } catch { + redirectWithState({ error: "Failed to create announcement." }) + } + + revalidatePath("/announcements") + revalidatePath("/") + redirectWithState({ notice: "Announcement created." }) +} + +async function updateAnnouncementAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/announcements", + permission: "banner:write", + scope: "global", + }) + + try { + await updateAnnouncement({ + id: readInputString(formData, "id"), + title: readInputString(formData, "title"), + message: readInputString(formData, "message"), + placement: readInputString(formData, "placement"), + priority: readInt(formData, "priority", 100), + ctaLabel: readNullableString(formData, "ctaLabel"), + ctaHref: readNullableString(formData, "ctaHref"), + startsAt: readNullableDate(formData, "startsAt"), + endsAt: readNullableDate(formData, "endsAt"), + isVisible: readInputString(formData, "isVisible") === "true", + }) + } catch { + redirectWithState({ error: "Failed to update announcement." }) + } + + revalidatePath("/announcements") + revalidatePath("/") + redirectWithState({ notice: "Announcement updated." }) +} + +async function deleteAnnouncementAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/announcements", + permission: "banner:write", + scope: "global", + }) + + try { + await deleteAnnouncement(readInputString(formData, "id")) + } catch { + redirectWithState({ error: "Failed to delete announcement." }) + } + + revalidatePath("/announcements") + revalidatePath("/") + redirectWithState({ notice: "Announcement deleted." }) +} + +function dateInputValue(value: Date | null): string { + if (!value) { + return "" + } + + return value.toISOString().slice(0, 10) +} + +export default async function AnnouncementsPage({ + searchParams, +}: { + searchParams: Promise +}) { + const role = await requirePermissionForRoute({ + nextPath: "/announcements", + permission: "banner:read", + scope: "global", + }) + + const [resolvedSearchParams, announcements] = await Promise.all([ + searchParams, + listAnnouncements(200), + ]) + + const notice = readFirstValue(resolvedSearchParams.notice) + const error = readFirstValue(resolvedSearchParams.error) + + return ( + + {notice ? ( +
+ {notice} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + +
+

Create Announcement

+
+ +