feat(media): add enrichment metadata fields across admin and public
This commit is contained in:
3
TODO.md
3
TODO.md
@@ -140,7 +140,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [ ] [P1] Page builder with reusable content blocks (hero, rich text, gallery, CTA, forms, price cards)
|
||||
- [~] [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 enrichment metadata (alt text, copyright, author, source, tags, licensing, usage context)
|
||||
- [x] [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] Artwork refinement fields (medium, dimensions, year, framing, availability, price visibility)
|
||||
- [ ] [P1] Artwork rendition management (thumbnail, card, full, retina/custom sizes)
|
||||
@@ -343,6 +343,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [2026-02-12] Upload storage is now provider-based (`local` + `s3`) via `CMS_MEDIA_STORAGE_PROVIDER`; admin-side GUI toggle remains a later MVP item.
|
||||
- [2026-02-12] Media storage keys now use asset-centric layout (`tenant/<id>/asset/<assetId>/<fileRole>/<assetId>__<variant>.<ext>`) with DB-managed media taxonomy.
|
||||
- [2026-02-12] Admin media CRUD now includes list-to-detail flow (`/media/:id`) with metadata edit and delete actions.
|
||||
- [2026-02-12] Media enrichment metadata baseline completed: `MediaAsset` now supports licensing/usage/location/captured-at fields across upload input, admin editor, and public artwork detail rendering.
|
||||
- [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.
|
||||
|
||||
@@ -58,6 +58,22 @@ function parseTags(formData: FormData): string[] {
|
||||
.filter((item) => item.length > 0)
|
||||
}
|
||||
|
||||
function parseOptionalDateField(formData: FormData, field: string): Date | undefined {
|
||||
const value = parseTextField(formData, field)
|
||||
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parsed = new Date(value)
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function deriveTitleFromFilename(fileName: string): string {
|
||||
const trimmed = fileName.trim()
|
||||
|
||||
@@ -178,6 +194,11 @@ export async function POST(request: Request): Promise<Response> {
|
||||
source: parseOptionalField(formData, "source"),
|
||||
copyright: parseOptionalField(formData, "copyright"),
|
||||
author: parseOptionalField(formData, "author"),
|
||||
licenseType: parseOptionalField(formData, "licenseType"),
|
||||
licenseUrl: parseOptionalField(formData, "licenseUrl"),
|
||||
usageContext: parseOptionalField(formData, "usageContext"),
|
||||
location: parseOptionalField(formData, "location"),
|
||||
capturedAt: parseOptionalDateField(formData, "capturedAt"),
|
||||
tags: parseTags(formData),
|
||||
storageKey: stored.storageKey,
|
||||
mimeType: fileEntry.type || undefined,
|
||||
|
||||
@@ -50,6 +50,22 @@ function readNullableInt(formData: FormData, field: string): number | null {
|
||||
return parsed
|
||||
}
|
||||
|
||||
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 readTags(formData: FormData): string[] {
|
||||
const raw = readInputString(formData, "tags")
|
||||
|
||||
@@ -127,6 +143,11 @@ export default async function MediaAssetEditorPage({ params, searchParams }: Pag
|
||||
source: readNullableString(formData, "source"),
|
||||
copyright: readNullableString(formData, "copyright"),
|
||||
author: readNullableString(formData, "author"),
|
||||
licenseType: readNullableString(formData, "licenseType"),
|
||||
licenseUrl: readNullableString(formData, "licenseUrl"),
|
||||
usageContext: readNullableString(formData, "usageContext"),
|
||||
location: readNullableString(formData, "location"),
|
||||
capturedAt: readNullableDate(formData, "capturedAt"),
|
||||
tags: readTags(formData),
|
||||
mimeType: readNullableString(formData, "mimeType"),
|
||||
width: readNullableInt(formData, "width"),
|
||||
@@ -320,6 +341,56 @@ export default async function MediaAssetEditorPage({ params, searchParams }: Pag
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">License type</span>
|
||||
<input
|
||||
name="licenseType"
|
||||
defaultValue={mediaAsset.licenseType ?? ""}
|
||||
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">License URL</span>
|
||||
<input
|
||||
name="licenseUrl"
|
||||
defaultValue={mediaAsset.licenseUrl ?? ""}
|
||||
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">Usage context</span>
|
||||
<input
|
||||
name="usageContext"
|
||||
defaultValue={mediaAsset.usageContext ?? ""}
|
||||
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">Location</span>
|
||||
<input
|
||||
name="location"
|
||||
defaultValue={mediaAsset.location ?? ""}
|
||||
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">Captured at</span>
|
||||
<input
|
||||
name="capturedAt"
|
||||
type="datetime-local"
|
||||
defaultValue={
|
||||
mediaAsset.capturedAt ? toLocalDateTimeInputValue(mediaAsset.capturedAt) : ""
|
||||
}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">MIME type</span>
|
||||
|
||||
@@ -149,6 +149,52 @@ export function MediaUploadForm() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">License type</span>
|
||||
<input
|
||||
name="licenseType"
|
||||
placeholder="e.g. CC BY-NC 4.0"
|
||||
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">License URL</span>
|
||||
<input
|
||||
name="licenseUrl"
|
||||
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">Usage context</span>
|
||||
<input
|
||||
name="usageContext"
|
||||
placeholder="e.g. homepage hero, social preview"
|
||||
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">Location</span>
|
||||
<input
|
||||
name="location"
|
||||
placeholder="e.g. Berlin studio"
|
||||
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">Captured at</span>
|
||||
<input
|
||||
name="capturedAt"
|
||||
type="datetime-local"
|
||||
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">Tags (comma-separated)</span>
|
||||
<input name="tags" className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" />
|
||||
|
||||
@@ -25,6 +25,8 @@ export default async function PublicArtworkPage({ params }: PublicArtworkPagePro
|
||||
notFound()
|
||||
}
|
||||
|
||||
const primaryMedia = artwork.renditions[0]?.mediaAsset ?? null
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-5xl space-y-6 px-6 py-16">
|
||||
<header className="space-y-2">
|
||||
@@ -94,6 +96,22 @@ export default async function PublicArtworkPage({ params }: PublicArtworkPagePro
|
||||
<strong>{t("fields.tags")}:</strong>{" "}
|
||||
{formatLabelList(artwork.tagLinks.map((entry) => entry.tag.name))}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.licenseType")}:</strong> {primaryMedia?.licenseType || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.licenseUrl")}:</strong> {primaryMedia?.licenseUrl || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.usageContext")}:</strong> {primaryMedia?.usageContext || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.location")}:</strong> {primaryMedia?.location || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.capturedAt")}:</strong>{" "}
|
||||
{primaryMedia?.capturedAt ? primaryMedia.capturedAt.toLocaleDateString("en-US") : "-"}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -87,7 +87,12 @@
|
||||
"galleries": "Galerien",
|
||||
"albums": "Alben",
|
||||
"categories": "Kategorien",
|
||||
"tags": "Tags"
|
||||
"tags": "Tags",
|
||||
"licenseType": "Lizenztyp",
|
||||
"licenseUrl": "Lizenz-URL",
|
||||
"usageContext": "Nutzungskontext",
|
||||
"location": "Ort",
|
||||
"capturedAt": "Aufgenommen am"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,12 @@
|
||||
"galleries": "Galleries",
|
||||
"albums": "Albums",
|
||||
"categories": "Categories",
|
||||
"tags": "Tags"
|
||||
"tags": "Tags",
|
||||
"licenseType": "License type",
|
||||
"licenseUrl": "License URL",
|
||||
"usageContext": "Usage context",
|
||||
"location": "Location",
|
||||
"capturedAt": "Captured at"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,12 @@
|
||||
"galleries": "Galerías",
|
||||
"albums": "Álbumes",
|
||||
"categories": "Categorías",
|
||||
"tags": "Etiquetas"
|
||||
"tags": "Etiquetas",
|
||||
"licenseType": "Tipo de licencia",
|
||||
"licenseUrl": "URL de licencia",
|
||||
"usageContext": "Contexto de uso",
|
||||
"location": "Ubicación",
|
||||
"capturedAt": "Capturado el"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,12 @@
|
||||
"galleries": "Galeries",
|
||||
"albums": "Albums",
|
||||
"categories": "Catégories",
|
||||
"tags": "Tags"
|
||||
"tags": "Tags",
|
||||
"licenseType": "Type de licence",
|
||||
"licenseUrl": "URL de licence",
|
||||
"usageContext": "Contexte d'utilisation",
|
||||
"location": "Lieu",
|
||||
"capturedAt": "Capturé le"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,14 @@ describe("media schemas", () => {
|
||||
const parsed = createMediaAssetInputSchema.parse({
|
||||
type: "artwork",
|
||||
title: "Artwork",
|
||||
licenseType: "CC BY",
|
||||
usageContext: "homepage hero",
|
||||
capturedAt: new Date("2026-01-02T10:30:00.000Z"),
|
||||
tags: ["tag-a"],
|
||||
})
|
||||
|
||||
expect(parsed.type).toBe("artwork")
|
||||
expect(parsed.licenseType).toBe("CC BY")
|
||||
expect(parsed.tags).toEqual(["tag-a"])
|
||||
})
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ export const createMediaAssetInputSchema = z.object({
|
||||
source: z.string().max(500).optional(),
|
||||
copyright: z.string().max(500).optional(),
|
||||
author: z.string().max(180).optional(),
|
||||
licenseType: z.string().max(120).optional(),
|
||||
licenseUrl: z.string().max(500).optional(),
|
||||
usageContext: z.string().max(300).optional(),
|
||||
location: z.string().max(180).optional(),
|
||||
capturedAt: z.date().optional(),
|
||||
tags: z.array(z.string().min(1).max(100)).default([]),
|
||||
storageKey: z.string().max(500).optional(),
|
||||
mimeType: z.string().max(180).optional(),
|
||||
@@ -38,6 +43,11 @@ export const updateMediaAssetInputSchema = z.object({
|
||||
source: z.string().max(500).nullable().optional(),
|
||||
copyright: z.string().max(500).nullable().optional(),
|
||||
author: z.string().max(180).nullable().optional(),
|
||||
licenseType: z.string().max(120).nullable().optional(),
|
||||
licenseUrl: z.string().max(500).nullable().optional(),
|
||||
usageContext: z.string().max(300).nullable().optional(),
|
||||
location: z.string().max(180).nullable().optional(),
|
||||
capturedAt: z.date().nullable().optional(),
|
||||
tags: z.array(z.string().min(1).max(100)).optional(),
|
||||
mimeType: z.string().max(180).nullable().optional(),
|
||||
width: z.number().int().positive().nullable().optional(),
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "MediaAsset"
|
||||
ADD COLUMN "licenseType" TEXT,
|
||||
ADD COLUMN "licenseUrl" TEXT,
|
||||
ADD COLUMN "usageContext" TEXT,
|
||||
ADD COLUMN "location" TEXT,
|
||||
ADD COLUMN "capturedAt" TIMESTAMP(3);
|
||||
@@ -123,6 +123,11 @@ model MediaAsset {
|
||||
source String?
|
||||
copyright String?
|
||||
author String?
|
||||
licenseType String?
|
||||
licenseUrl String?
|
||||
usageContext String?
|
||||
location String?
|
||||
capturedAt DateTime?
|
||||
tags String[]
|
||||
storageKey String? @unique
|
||||
mimeType String?
|
||||
|
||||
@@ -484,6 +484,11 @@ export async function getPublishedArtworkBySlug(slug: string) {
|
||||
source: true,
|
||||
author: true,
|
||||
copyright: true,
|
||||
licenseType: true,
|
||||
licenseUrl: true,
|
||||
usageContext: true,
|
||||
location: true,
|
||||
capturedAt: true,
|
||||
tags: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user