diff --git a/TODO.md b/TODO.md index 6fa688d..945a79b 100644 --- a/TODO.md +++ b/TODO.md @@ -118,7 +118,7 @@ This file is the single source of truth for roadmap and delivery progress. ### MVP1 Suggested Branch Order -- [~] [P1] `todo/mvp1-media-foundation`: +- [x] [P1] `todo/mvp1-media-foundation`: media model, artwork entity, grouping primitives (gallery/album/category/tag), rendition slots - [ ] [P1] `todo/mvp1-media-upload-pipeline`: S3/local upload adapter, media processing presets, metadata input flows, admin media CRUD UI @@ -269,6 +269,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-11] `gaertan` inspiration to reuse: S3 object strategy with signed delivery, commission type/options/extras/custom-input modeling, request-status kanban mapping, and gallery rendition/color extraction patterns. - [2026-02-11] MVP1 media foundation started: portfolio domain models (`MediaAsset`, `Artwork`, galleries/albums/categories/tags, rendition links) plus initial admin `/media` and `/portfolio` data views. - [2026-02-11] `prisma migrate dev --name media_foundation` can fail when DB endpoint is unreachable; apply this named migration once `DATABASE_URL` host is reachable again. +- [2026-02-11] MVP1 media foundation now includes baseline create/link workflows in admin (`/media`, `/portfolio`), seeded sample portfolio entities, and schema/service test coverage. ## How We Use This File diff --git a/apps/admin/src/app/media/page.tsx b/apps/admin/src/app/media/page.tsx index fbcf217..b189fbb 100644 --- a/apps/admin/src/app/media/page.tsx +++ b/apps/admin/src/app/media/page.tsx @@ -1,17 +1,109 @@ -import { getMediaFoundationSummary, listMediaAssets } from "@cms/db" +import { createMediaAsset, getMediaFoundationSummary, listMediaAssets } 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" -export default async function MediaManagementPage() { +type SearchParamsInput = Record + +function readFirstValue(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null + } + + return value ?? null +} + +function readField(formData: FormData, field: string): string { + const value = formData.get(field) + return typeof value === "string" ? value.trim() : "" +} + +function readOptionalField(formData: FormData, field: string): string | undefined { + const value = readField(formData, field) + return value.length > 0 ? value : undefined +} + +function readTags(formData: FormData, field: string): string[] { + const raw = readField(formData, field) + + if (!raw) { + return [] + } + + return raw + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0) +} + +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 ? `/media?${value}` : "/media") +} + +async function createMediaAssetAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/media", + permission: "media:write", + scope: "team", + }) + + try { + await createMediaAsset({ + title: readField(formData, "title"), + type: readField(formData, "type"), + description: readOptionalField(formData, "description"), + altText: readOptionalField(formData, "altText"), + source: readOptionalField(formData, "source"), + copyright: readOptionalField(formData, "copyright"), + author: readOptionalField(formData, "author"), + tags: readTags(formData, "tags"), + }) + } catch { + redirectWithState({ + error: "Failed to create media asset. Validate required fields and try again.", + }) + } + + revalidatePath("/media") + revalidatePath("/portfolio") + redirectWithState({ notice: "Media asset created." }) +} + +export default async function MediaManagementPage({ + searchParams, +}: { + searchParams: Promise +}) { const role = await requirePermissionForRoute({ nextPath: "/media", permission: "media:read", scope: "team", }) - const [summary, assets] = await Promise.all([getMediaFoundationSummary(), listMediaAssets(20)]) + const [resolvedSearchParams, summary, assets] = await Promise.all([ + searchParams, + getMediaFoundationSummary(), + listMediaAssets(20), + ]) + const notice = readFirstValue(resolvedSearchParams.notice) + const error = readFirstValue(resolvedSearchParams.error) return ( + {notice ? ( +
+ {notice} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} +

Media Assets

@@ -43,6 +147,86 @@ export default async function MediaManagementPage() {
+
+

Create Media Asset

+
+
+ + +
+