feat(media): complete mvp1 media foundation workflows

This commit is contained in:
2026-02-11 22:56:01 +01:00
parent d727ab8b5b
commit ad351ed73a
9 changed files with 1028 additions and 8 deletions

View File

@@ -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<string, string | string[] | undefined>
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<SearchParamsInput>
}) {
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 (
<AdminShell
@@ -21,6 +113,18 @@ export default async function MediaManagementPage() {
title="Media"
description="Media foundation baseline for assets, artwork renditions, and grouping metadata."
>
{notice ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{notice}
</section>
) : null}
{error ? (
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</section>
) : null}
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<article className="rounded-xl border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Media Assets</p>
@@ -43,6 +147,86 @@ export default async function MediaManagementPage() {
</article>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Media Asset</h2>
<form action={createMediaAssetAction} className="mt-4 space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
required
minLength={1}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Type</span>
<select
name="type"
defaultValue="artwork"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="artwork">artwork</option>
<option value="banner">banner</option>
<option value="promotion">promotion</option>
<option value="video">video</option>
<option value="gif">gif</option>
<option value="generic">generic</option>
</select>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Description</span>
<textarea
name="description"
rows={3}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Alt text</span>
<input
name="altText"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Author</span>
<input
name="author"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Source</span>
<input
name="source"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Copyright</span>
<input
name="copyright"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Tags (comma-separated)</span>
<input
name="tags"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<Button type="submit">Create media asset</Button>
</form>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<div className="flex items-center justify-between gap-2">
<h2 className="text-xl font-medium">Recent Media Assets</h2>