feat(media): add mvp1 upload pipeline baseline
This commit is contained in:
@@ -10,6 +10,9 @@ CMS_SUPPORT_EMAIL="support@cms.local"
|
|||||||
CMS_SUPPORT_PASSWORD="change-me-support-password"
|
CMS_SUPPORT_PASSWORD="change-me-support-password"
|
||||||
CMS_SUPPORT_NAME="Technical Support"
|
CMS_SUPPORT_NAME="Technical Support"
|
||||||
CMS_SUPPORT_LOGIN_KEY="support-access-change-me"
|
CMS_SUPPORT_LOGIN_KEY="support-access-change-me"
|
||||||
|
CMS_MEDIA_UPLOAD_MAX_BYTES="26214400"
|
||||||
|
# Optional: override local media storage directory for admin upload adapter.
|
||||||
|
# CMS_MEDIA_LOCAL_STORAGE_DIR="/absolute/path/to/media-storage"
|
||||||
NEXT_PUBLIC_APP_VERSION="0.1.0-dev"
|
NEXT_PUBLIC_APP_VERSION="0.1.0-dev"
|
||||||
NEXT_PUBLIC_GIT_SHA="local"
|
NEXT_PUBLIC_GIT_SHA="local"
|
||||||
# Optional dev bypass role for admin middleware. Leave empty to require auth login.
|
# Optional dev bypass role for admin middleware. Leave empty to require auth login.
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -32,3 +32,7 @@ packages/db/prisma/generated/
|
|||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# local media storage
|
||||||
|
.data/
|
||||||
|
apps/admin/.data/
|
||||||
|
|||||||
5
TODO.md
5
TODO.md
@@ -120,7 +120,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
|
|
||||||
- [x] [P1] `todo/mvp1-media-foundation`:
|
- [x] [P1] `todo/mvp1-media-foundation`:
|
||||||
media model, artwork entity, grouping primitives (gallery/album/category/tag), rendition slots
|
media model, artwork entity, grouping primitives (gallery/album/category/tag), rendition slots
|
||||||
- [ ] [P1] `todo/mvp1-media-upload-pipeline`:
|
- [~] [P1] `todo/mvp1-media-upload-pipeline`:
|
||||||
S3/local upload adapter, media processing presets, metadata input flows, admin media CRUD UI
|
S3/local upload adapter, media processing presets, metadata input flows, admin media CRUD UI
|
||||||
- [ ] [P1] `todo/mvp1-pages-navigation-builder`:
|
- [ ] [P1] `todo/mvp1-pages-navigation-builder`:
|
||||||
page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds)
|
page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds)
|
||||||
@@ -147,7 +147,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
- [ ] [P1] Page management (create/edit/publish/unpublish/schedule)
|
- [ ] [P1] Page management (create/edit/publish/unpublish/schedule)
|
||||||
- [ ] [P1] Page builder with reusable content blocks (hero, rich text, gallery, CTA, forms, price cards)
|
- [ ] [P1] Page builder with reusable content blocks (hero, rich text, gallery, CTA, forms, price cards)
|
||||||
- [ ] [P1] Navigation management (menus, nested items, order, visibility)
|
- [ ] [P1] Navigation management (menus, nested items, order, visibility)
|
||||||
- [ ] [P1] Media library (upload, browse, replace, delete) with media-type classification (artwork, banner, promo, generic, video/gif)
|
- [~] [P1] Media library (upload, browse, replace, delete) with media-type classification (artwork, banner, promo, generic, video/gif)
|
||||||
- [ ] [P1] Media enrichment metadata (alt text, copyright, author, source, tags, licensing, usage context)
|
- [ ] [P1] Media enrichment metadata (alt text, copyright, author, source, tags, licensing, usage context)
|
||||||
- [ ] [P1] Portfolio grouping primitives (galleries, albums, categories, tags) with ordering/visibility controls
|
- [ ] [P1] Portfolio grouping primitives (galleries, albums, categories, tags) with ordering/visibility controls
|
||||||
- [ ] [P1] Artwork refinement fields (medium, dimensions, year, framing, availability, price visibility)
|
- [ ] [P1] Artwork refinement fields (medium, dimensions, year, framing, availability, price visibility)
|
||||||
@@ -270,6 +270,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
- [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] 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] `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.
|
- [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.
|
||||||
|
- [2026-02-12] MVP1 media upload pipeline started: admin `/api/media/upload` accepts metadata + file upload with permission checks, stores files via local adapter (`.data/media`), and persists upload metadata to `MediaAsset`.
|
||||||
|
|
||||||
## How We Use This File
|
## How We Use This File
|
||||||
|
|
||||||
|
|||||||
185
apps/admin/src/app/api/media/upload/route.ts
Normal file
185
apps/admin/src/app/api/media/upload/route.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { hasPermission } from "@cms/content/rbac"
|
||||||
|
import { createMediaAsset } from "@cms/db"
|
||||||
|
|
||||||
|
import { auth, resolveRoleFromAuthSession } from "@/lib/auth/server"
|
||||||
|
import { storeUploadLocally } from "@/lib/media/local-storage"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
const MAX_UPLOAD_BYTES = Number(process.env.CMS_MEDIA_UPLOAD_MAX_BYTES ?? 25 * 1024 * 1024)
|
||||||
|
|
||||||
|
type AllowedRule = {
|
||||||
|
mimePrefix?: string
|
||||||
|
mimeExact?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_MIME_BY_TYPE: Record<string, AllowedRule> = {
|
||||||
|
artwork: {
|
||||||
|
mimePrefix: "image/",
|
||||||
|
},
|
||||||
|
banner: {
|
||||||
|
mimePrefix: "image/",
|
||||||
|
},
|
||||||
|
promotion: {
|
||||||
|
mimePrefix: "image/",
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
mimePrefix: "video/",
|
||||||
|
},
|
||||||
|
gif: {
|
||||||
|
mimeExact: ["image/gif"],
|
||||||
|
},
|
||||||
|
generic: {
|
||||||
|
mimePrefix: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTextField(formData: FormData, field: string): string {
|
||||||
|
const value = formData.get(field)
|
||||||
|
return typeof value === "string" ? value.trim() : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalField(formData: FormData, field: string): string | undefined {
|
||||||
|
const value = parseTextField(formData, field)
|
||||||
|
return value.length > 0 ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTags(formData: FormData): string[] {
|
||||||
|
const value = parseTextField(formData, "tags")
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMimeAllowed(mediaType: string, mimeType: string): boolean {
|
||||||
|
const rule = ALLOWED_MIME_BY_TYPE[mediaType]
|
||||||
|
|
||||||
|
if (!rule) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.mimeExact?.includes(mimeType)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.mimePrefix === "") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return rule.mimePrefix ? mimeType.startsWith(rule.mimePrefix) : false
|
||||||
|
}
|
||||||
|
|
||||||
|
function badRequest(message: string): Response {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request): Promise<Response> {
|
||||||
|
const session = await auth.api
|
||||||
|
.getSession({
|
||||||
|
headers: request.headers,
|
||||||
|
})
|
||||||
|
.catch(() => null)
|
||||||
|
const role = resolveRoleFromAuthSession(session)
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
message: "Unauthorized",
|
||||||
|
},
|
||||||
|
{ status: 401 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(role, "media:write", "team")) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
message: "Missing permission: media:write",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData().catch(() => null)
|
||||||
|
|
||||||
|
if (!formData) {
|
||||||
|
return badRequest("Invalid form payload.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = parseTextField(formData, "title")
|
||||||
|
const type = parseTextField(formData, "type")
|
||||||
|
const fileEntry = formData.get("file")
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return badRequest("Title is required.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
return badRequest("Type is required.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(fileEntry instanceof File)) {
|
||||||
|
return badRequest("File is required.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileEntry.size === 0) {
|
||||||
|
return badRequest("File is empty.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileEntry.size > MAX_UPLOAD_BYTES) {
|
||||||
|
return badRequest(
|
||||||
|
`File is too large. Maximum upload is ${Math.floor(MAX_UPLOAD_BYTES / 1024 / 1024)} MB.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMimeAllowed(type, fileEntry.type)) {
|
||||||
|
return badRequest(`File type ${fileEntry.type || "unknown"} is not allowed for ${type}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = await storeUploadLocally({
|
||||||
|
file: fileEntry,
|
||||||
|
mediaType: type,
|
||||||
|
})
|
||||||
|
|
||||||
|
const created = await createMediaAsset({
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
description: parseOptionalField(formData, "description"),
|
||||||
|
altText: parseOptionalField(formData, "altText"),
|
||||||
|
source: parseOptionalField(formData, "source"),
|
||||||
|
copyright: parseOptionalField(formData, "copyright"),
|
||||||
|
author: parseOptionalField(formData, "author"),
|
||||||
|
tags: parseTags(formData),
|
||||||
|
storageKey: stored.storageKey,
|
||||||
|
mimeType: fileEntry.type || undefined,
|
||||||
|
sizeBytes: fileEntry.size,
|
||||||
|
isPublished: parseTextField(formData, "isPublished") === "true",
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
id: created.id,
|
||||||
|
notice: "Media uploaded successfully.",
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
message: "Upload failed. Please try again.",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { createMediaAsset, getMediaFoundationSummary, listMediaAssets } from "@cms/db"
|
import { 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 { AdminShell } from "@/components/admin-shell"
|
||||||
|
import { MediaUploadForm } from "@/components/media/media-upload-form"
|
||||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -18,75 +16,6 @@ function readFirstValue(value: string | string[] | undefined): string | null {
|
|||||||
return value ?? 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({
|
export default async function MediaManagementPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -141,97 +70,25 @@ export default async function MediaManagementPage({
|
|||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-neutral-500">
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
{summary.galleries} galleries · {summary.albums} albums · {summary.categories}{" "}
|
{summary.galleries} galleries · {summary.albums} albums · {summary.categories}{" "}
|
||||||
categories{" · "}
|
categories · {summary.tags} tags
|
||||||
{summary.tags} tags
|
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-neutral-200 p-6">
|
<section className="rounded-xl border border-neutral-200 p-6">
|
||||||
<h2 className="text-xl font-medium">Create Media Asset</h2>
|
<h2 className="text-xl font-medium">Upload Media Asset</h2>
|
||||||
<form action={createMediaAssetAction} className="mt-4 space-y-3">
|
<p className="mt-1 text-sm text-neutral-600">
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
Files are currently stored via local adapter. S3/object storage is the next incremental
|
||||||
<label className="space-y-1">
|
step.
|
||||||
<span className="text-xs text-neutral-600">Title</span>
|
</p>
|
||||||
<input
|
<MediaUploadForm />
|
||||||
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>
|
||||||
|
|
||||||
<section className="rounded-xl border border-neutral-200 p-6">
|
<section className="rounded-xl border border-neutral-200 p-6">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h2 className="text-xl font-medium">Recent Media Assets</h2>
|
<h2 className="text-xl font-medium">Recent Media Assets</h2>
|
||||||
<span className="text-xs uppercase tracking-[0.2em] text-neutral-500">
|
<span className="text-xs uppercase tracking-[0.2em] text-neutral-500">
|
||||||
MVP1 Foundation
|
MVP1 Upload Pipeline
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 overflow-x-auto">
|
<div className="mt-4 overflow-x-auto">
|
||||||
@@ -240,6 +97,8 @@ export default async function MediaManagementPage({
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="py-2 pr-4">Title</th>
|
<th className="py-2 pr-4">Title</th>
|
||||||
<th className="py-2 pr-4">Type</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">Published</th>
|
||||||
<th className="py-2 pr-4">Updated</th>
|
<th className="py-2 pr-4">Updated</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -247,8 +106,8 @@ export default async function MediaManagementPage({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{assets.length === 0 ? (
|
{assets.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="py-3 text-neutral-500" colSpan={4}>
|
<td className="py-3 text-neutral-500" colSpan={6}>
|
||||||
No media assets yet. Upload workflows land in `todo/mvp1-media-upload-pipeline`.
|
No media assets yet. Upload your first asset above.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
@@ -256,6 +115,12 @@ export default async function MediaManagementPage({
|
|||||||
<tr key={asset.id} className="border-t border-neutral-200">
|
<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.title}</td>
|
||||||
<td className="py-3 pr-4">{asset.type}</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">{asset.isPublished ? "yes" : "no"}</td>
|
||||||
<td className="py-3 pr-4 text-neutral-600">
|
<td className="py-3 pr-4 text-neutral-600">
|
||||||
{asset.updatedAt.toLocaleDateString("en-US")}
|
{asset.updatedAt.toLocaleDateString("en-US")}
|
||||||
|
|||||||
163
apps/admin/src/components/media/media-upload-form.tsx
Normal file
163
apps/admin/src/components/media/media-upload-form.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@cms/ui/button"
|
||||||
|
import { type FormEvent, useState } from "react"
|
||||||
|
|
||||||
|
type MediaType = "artwork" | "banner" | "promotion" | "video" | "gif" | "generic"
|
||||||
|
|
||||||
|
const ACCEPT_BY_TYPE: Record<MediaType, string> = {
|
||||||
|
artwork: "image/jpeg,image/png,image/webp,image/avif,image/gif",
|
||||||
|
banner: "image/jpeg,image/png,image/webp,image/avif",
|
||||||
|
promotion: "image/jpeg,image/png,image/webp,image/avif,image/gif,video/mp4,video/webm",
|
||||||
|
video: "video/mp4,video/webm,video/quicktime",
|
||||||
|
gif: "image/gif",
|
||||||
|
generic: "image/*,video/*",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaUploadForm() {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [mediaType, setMediaType] = useState<MediaType>("artwork")
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
const form = event.currentTarget
|
||||||
|
const formData = new FormData(form)
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/media/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = (await response.json().catch(() => null)) as {
|
||||||
|
message?: string
|
||||||
|
} | null
|
||||||
|
|
||||||
|
setError(payload?.message ?? "Upload failed. Please verify file and metadata.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as {
|
||||||
|
notice?: string
|
||||||
|
} | null
|
||||||
|
|
||||||
|
const notice = payload?.notice ?? "Media uploaded."
|
||||||
|
window.location.href = `/media?notice=${encodeURIComponent(notice)}`
|
||||||
|
} catch {
|
||||||
|
setError("Upload request failed. Please retry.")
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
|
||||||
|
{error ? (
|
||||||
|
<p className="rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<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"
|
||||||
|
value={mediaType}
|
||||||
|
onChange={(event) => setMediaType(event.target.value as MediaType)}
|
||||||
|
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">File</span>
|
||||||
|
<input
|
||||||
|
name="file"
|
||||||
|
type="file"
|
||||||
|
required
|
||||||
|
accept={ACCEPT_BY_TYPE[mediaType]}
|
||||||
|
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">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>
|
||||||
|
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||||
|
<input name="isPublished" type="checkbox" value="true" className="size-4" />
|
||||||
|
Publish immediately
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Uploading..." : "Upload media"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
apps/admin/src/lib/media/local-storage.ts
Normal file
66
apps/admin/src/lib/media/local-storage.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
type StoreUploadParams = {
|
||||||
|
file: File
|
||||||
|
mediaType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoredUpload = {
|
||||||
|
storageKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_EXTENSION = "bin"
|
||||||
|
|
||||||
|
function resolveBaseDirectory(): string {
|
||||||
|
const configured = process.env.CMS_MEDIA_LOCAL_STORAGE_DIR?.trim()
|
||||||
|
|
||||||
|
if (configured) {
|
||||||
|
return path.resolve(configured)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.resolve(process.cwd(), ".data", "media")
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSegment(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionFromFilename(fileName: string): string {
|
||||||
|
const extension = path.extname(fileName).slice(1)
|
||||||
|
|
||||||
|
if (!extension) {
|
||||||
|
return FALLBACK_EXTENSION
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeSegment(extension)
|
||||||
|
|
||||||
|
return normalized.length > 0 ? normalized : FALLBACK_EXTENSION
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStorageKey(mediaType: string, fileName: string): string {
|
||||||
|
const now = new Date()
|
||||||
|
const year = String(now.getUTCFullYear())
|
||||||
|
const month = String(now.getUTCMonth() + 1).padStart(2, "0")
|
||||||
|
const normalizedType = normalizeSegment(mediaType) || "generic"
|
||||||
|
const extension = extensionFromFilename(fileName)
|
||||||
|
|
||||||
|
return [normalizedType, year, month, `${randomUUID()}.${extension}`].join("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeUploadLocally(params: StoreUploadParams): Promise<StoredUpload> {
|
||||||
|
const storageKey = buildStorageKey(params.mediaType, params.file.name)
|
||||||
|
const baseDirectory = resolveBaseDirectory()
|
||||||
|
const outputPath = path.join(baseDirectory, storageKey)
|
||||||
|
|
||||||
|
await mkdir(path.dirname(outputPath), { recursive: true })
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(await params.file.arrayBuffer())
|
||||||
|
await writeFile(outputPath, bytes)
|
||||||
|
|
||||||
|
return { storageKey }
|
||||||
|
}
|
||||||
@@ -20,6 +20,12 @@ export const createMediaAssetInputSchema = z.object({
|
|||||||
copyright: z.string().max(500).optional(),
|
copyright: z.string().max(500).optional(),
|
||||||
author: z.string().max(180).optional(),
|
author: z.string().max(180).optional(),
|
||||||
tags: z.array(z.string().min(1).max(100)).default([]),
|
tags: z.array(z.string().min(1).max(100)).default([]),
|
||||||
|
storageKey: z.string().max(500).optional(),
|
||||||
|
mimeType: z.string().max(180).optional(),
|
||||||
|
sizeBytes: z.number().int().min(0).optional(),
|
||||||
|
width: z.number().int().positive().optional(),
|
||||||
|
height: z.number().int().positive().optional(),
|
||||||
|
isPublished: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createArtworkInputSchema = z.object({
|
export const createArtworkInputSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user