From b9424c8a8bd252c79c986fc2e422c2827f850746 Mon Sep 17 00:00:00 2001
From: Citali
Date: Thu, 12 Feb 2026 22:42:08 +0100
Subject: [PATCH] feat(media): add enrichment metadata fields across admin and
public
---
TODO.md | 3 +-
apps/admin/src/app/api/media/upload/route.ts | 21 ++++++
apps/admin/src/app/media/[id]/page.tsx | 71 +++++++++++++++++++
.../components/media/media-upload-form.tsx | 46 ++++++++++++
.../app/[locale]/portfolio/[slug]/page.tsx | 18 +++++
apps/web/src/messages/de.json | 7 +-
apps/web/src/messages/en.json | 7 +-
apps/web/src/messages/es.json | 7 +-
apps/web/src/messages/fr.json | 7 +-
packages/content/src/media.test.ts | 4 ++
packages/content/src/media.ts | 10 +++
.../migration.sql | 6 ++
packages/db/prisma/schema.prisma | 5 ++
packages/db/src/media-foundation.ts | 5 ++
14 files changed, 212 insertions(+), 5 deletions(-)
create mode 100644 packages/db/prisma/migrations/20260212234000_media_enrichment_metadata/migration.sql
diff --git a/TODO.md b/TODO.md
index c1c92c1..3c5f2eb 100644
--- a/TODO.md
+++ b/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//asset///__.`) 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.
diff --git a/apps/admin/src/app/api/media/upload/route.ts b/apps/admin/src/app/api/media/upload/route.ts
index 7537e54..47e9f6f 100644
--- a/apps/admin/src/app/api/media/upload/route.ts
+++ b/apps/admin/src/app/api/media/upload/route.ts
@@ -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 {
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,
diff --git a/apps/admin/src/app/media/[id]/page.tsx b/apps/admin/src/app/media/[id]/page.tsx
index dd62a32..e856651 100644
--- a/apps/admin/src/app/media/[id]/page.tsx
+++ b/apps/admin/src/app/media/[id]/page.tsx
@@ -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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("fields.licenseType")}: {primaryMedia?.licenseType || "-"}
+
+
+ {t("fields.licenseUrl")}: {primaryMedia?.licenseUrl || "-"}
+
+
+ {t("fields.usageContext")}: {primaryMedia?.usageContext || "-"}
+
+
+ {t("fields.location")}: {primaryMedia?.location || "-"}
+
+
+ {t("fields.capturedAt")}:{" "}
+ {primaryMedia?.capturedAt ? primaryMedia.capturedAt.toLocaleDateString("en-US") : "-"}
+
diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json
index 0e9c9b3..1f8c217 100644
--- a/apps/web/src/messages/de.json
+++ b/apps/web/src/messages/de.json
@@ -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"
}
}
}
diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json
index 29a1e2f..097b1f4 100644
--- a/apps/web/src/messages/en.json
+++ b/apps/web/src/messages/en.json
@@ -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"
}
}
}
diff --git a/apps/web/src/messages/es.json b/apps/web/src/messages/es.json
index ec9df7b..48afc05 100644
--- a/apps/web/src/messages/es.json
+++ b/apps/web/src/messages/es.json
@@ -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"
}
}
}
diff --git a/apps/web/src/messages/fr.json b/apps/web/src/messages/fr.json
index 909ab53..c2be966 100644
--- a/apps/web/src/messages/fr.json
+++ b/apps/web/src/messages/fr.json
@@ -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"
}
}
}
diff --git a/packages/content/src/media.test.ts b/packages/content/src/media.test.ts
index 4d23183..bdde640 100644
--- a/packages/content/src/media.test.ts
+++ b/packages/content/src/media.test.ts
@@ -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"])
})
diff --git a/packages/content/src/media.ts b/packages/content/src/media.ts
index 5dfcc20..ba724f8 100644
--- a/packages/content/src/media.ts
+++ b/packages/content/src/media.ts
@@ -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(),
diff --git a/packages/db/prisma/migrations/20260212234000_media_enrichment_metadata/migration.sql b/packages/db/prisma/migrations/20260212234000_media_enrichment_metadata/migration.sql
new file mode 100644
index 0000000..1ff0182
--- /dev/null
+++ b/packages/db/prisma/migrations/20260212234000_media_enrichment_metadata/migration.sql
@@ -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);
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index c7d46b3..c1c9fc3 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -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?
diff --git a/packages/db/src/media-foundation.ts b/packages/db/src/media-foundation.ts
index 11831bc..cfaf22f 100644
--- a/packages/db/src/media-foundation.ts
+++ b/packages/db/src/media-foundation.ts
@@ -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,
},
},