feat(media): scaffold mvp1 media and portfolio foundation

This commit is contained in:
2026-02-11 22:46:24 +01:00
parent 5b47fafe89
commit d727ab8b5b
14 changed files with 674 additions and 16 deletions

View File

@@ -1,4 +1,5 @@
import { AdminSectionPlaceholder } from "@/components/admin-section-placeholder"
import { getMediaFoundationSummary, listMediaAssets } from "@cms/db"
import { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards"
@@ -10,6 +11,7 @@ export default async function MediaManagementPage() {
permission: "media:read",
scope: "team",
})
const [summary, assets] = await Promise.all([getMediaFoundationSummary(), listMediaAssets(20)])
return (
<AdminShell
@@ -17,18 +19,70 @@ export default async function MediaManagementPage() {
activePath="/media"
badge="Admin App"
title="Media"
description="Prepare media library and enrichment workflows."
description="Media foundation baseline for assets, artwork renditions, and grouping metadata."
>
<AdminSectionPlaceholder
feature="Media Library"
summary="This route is ready for media browsing, upload, and metadata refinement features."
requiredPermission="media:read (team)"
nextSteps={[
"Add media upload and asset listing.",
"Add enrichment fields (alt text, source, tags).",
"Add artwork-specific refinement fields.",
]}
/>
<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>
<p className="mt-2 text-3xl font-semibold">{summary.mediaAssets}</p>
</article>
<article className="rounded-xl border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Artworks</p>
<p className="mt-2 text-3xl font-semibold">{summary.artworks}</p>
</article>
<article className="rounded-xl border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Groups</p>
<p className="mt-2 text-3xl font-semibold">
{summary.galleries + summary.albums + summary.categories + summary.tags}
</p>
<p className="mt-1 text-xs text-neutral-500">
{summary.galleries} galleries · {summary.albums} albums · {summary.categories}{" "}
categories{" · "}
{summary.tags} tags
</p>
</article>
</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
</span>
</div>
<div className="mt-4 overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wide text-neutral-500">
<tr>
<th className="py-2 pr-4">Title</th>
<th className="py-2 pr-4">Type</th>
<th className="py-2 pr-4">Published</th>
<th className="py-2 pr-4">Updated</th>
</tr>
</thead>
<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>
</tr>
) : (
assets.map((asset) => (
<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">{asset.isPublished ? "yes" : "no"}</td>
<td className="py-3 pr-4 text-neutral-600">
{asset.updatedAt.toLocaleDateString("en-US")}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</AdminShell>
)
}

View File

@@ -0,0 +1,69 @@
import { listArtworks } from "@cms/db"
import { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
export default async function PortfolioPage() {
const role = await requirePermissionForRoute({
nextPath: "/portfolio",
permission: "media:read",
scope: "team",
})
const artworks = await listArtworks(30)
return (
<AdminShell
role={role}
activePath="/portfolio"
badge="Admin App"
title="Portfolio"
description="Artwork foundation with rendition slots and grouping relations."
>
<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">Artworks</h2>
<span className="text-xs uppercase tracking-[0.2em] text-neutral-500">
MVP1 Foundation
</span>
</div>
<div className="mt-4 overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wide text-neutral-500">
<tr>
<th className="py-2 pr-4">Title</th>
<th className="py-2 pr-4">Slug</th>
<th className="py-2 pr-4">Published</th>
<th className="py-2 pr-4">Renditions</th>
<th className="py-2 pr-4">Groups</th>
</tr>
</thead>
<tbody>
{artworks.length === 0 ? (
<tr>
<td className="py-3 text-neutral-500" colSpan={5}>
No artworks yet. Add creation flows after media upload pipeline lands.
</td>
</tr>
) : (
artworks.map((artwork) => (
<tr key={artwork.id} className="border-t border-neutral-200">
<td className="py-3 pr-4">{artwork.title}</td>
<td className="py-3 pr-4 font-mono text-xs">{artwork.slug}</td>
<td className="py-3 pr-4">{artwork.isPublished ? "yes" : "no"}</td>
<td className="py-3 pr-4">{artwork.renditions.length}</td>
<td className="py-3 pr-4 text-neutral-600">
g:{artwork.galleryLinks.length} a:{artwork.albumLinks.length} c:
{artwork.categoryLinks.length} t:{artwork.tagLinks.length}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</AdminShell>
)
}

View File

@@ -27,6 +27,7 @@ const navItems: NavItem[] = [
{ href: "/", label: "Dashboard", permission: "dashboard:read", scope: "global" },
{ href: "/pages", label: "Pages", permission: "pages:read", scope: "team" },
{ href: "/media", label: "Media", permission: "media:read", scope: "team" },
{ href: "/portfolio", label: "Portfolio", permission: "media:read", scope: "team" },
{ href: "/users", label: "Users", permission: "users:read", scope: "own" },
{ href: "/commissions", label: "Commissions", permission: "commissions:read", scope: "own" },
{ href: "/settings", label: "Settings", permission: "users:manage_roles", scope: "global" },

View File

@@ -31,6 +31,10 @@ describe("admin route access rules", () => {
permission: "media:read",
scope: "team",
})
expect(getRequiredPermission("/portfolio")).toEqual({
permission: "media:read",
scope: "team",
})
expect(getRequiredPermission("/users")).toEqual({
permission: "users:read",
scope: "own",

View File

@@ -57,6 +57,13 @@ const guardRules: GuardRule[] = [
scope: "team",
},
},
{
route: /^\/portfolio(?:\/|$)/,
requirement: {
permission: "media:read",
scope: "team",
},
},
{
route: /^\/users(?:\/|$)/,
requirement: {