feat(media): add mvp1 upload pipeline baseline
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
import { createMediaAsset, getMediaFoundationSummary, listMediaAssets } from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
import { getMediaFoundationSummary, listMediaAssets } from "@cms/db"
|
||||
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import { MediaUploadForm } from "@/components/media/media-upload-form"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -18,75 +16,6 @@ function readFirstValue(value: string | string[] | undefined): string | 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,
|
||||
}: {
|
||||
@@ -141,97 +70,25 @@ export default async function MediaManagementPage({
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{summary.galleries} galleries · {summary.albums} albums · {summary.categories}{" "}
|
||||
categories{" · "}
|
||||
{summary.tags} tags
|
||||
categories · {summary.tags} tags
|
||||
</p>
|
||||
</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>
|
||||
<h2 className="text-xl font-medium">Upload Media Asset</h2>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
Files are currently stored via local adapter. S3/object storage is the next incremental
|
||||
step.
|
||||
</p>
|
||||
<MediaUploadForm />
|
||||
</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>
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-neutral-500">
|
||||
MVP1 Foundation
|
||||
MVP1 Upload Pipeline
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
@@ -240,6 +97,8 @@ export default async function MediaManagementPage({
|
||||
<tr>
|
||||
<th className="py-2 pr-4">Title</th>
|
||||
<th className="py-2 pr-4">Type</th>
|
||||
<th className="py-2 pr-4">MIME</th>
|
||||
<th className="py-2 pr-4">Size</th>
|
||||
<th className="py-2 pr-4">Published</th>
|
||||
<th className="py-2 pr-4">Updated</th>
|
||||
</tr>
|
||||
@@ -247,8 +106,8 @@ export default async function MediaManagementPage({
|
||||
<tbody>
|
||||
{assets.length === 0 ? (
|
||||
<tr>
|
||||
<td className="py-3 text-neutral-500" colSpan={4}>
|
||||
No media assets yet. Upload workflows land in `todo/mvp1-media-upload-pipeline`.
|
||||
<td className="py-3 text-neutral-500" colSpan={6}>
|
||||
No media assets yet. Upload your first asset above.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -256,6 +115,12 @@ export default async function MediaManagementPage({
|
||||
<tr key={asset.id} className="border-t border-neutral-200">
|
||||
<td className="py-3 pr-4">{asset.title}</td>
|
||||
<td className="py-3 pr-4">{asset.type}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{asset.mimeType ?? "-"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">
|
||||
{typeof asset.sizeBytes === "number"
|
||||
? `${Math.max(1, Math.round(asset.sizeBytes / 1024))} KB`
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">{asset.isPublished ? "yes" : "no"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">
|
||||
{asset.updatedAt.toLocaleDateString("en-US")}
|
||||
|
||||
Reference in New Issue
Block a user