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

@@ -118,7 +118,7 @@ This file is the single source of truth for roadmap and delivery progress.
### MVP1 Suggested Branch Order ### MVP1 Suggested Branch Order
- [ ] [P1] `todo/mvp1-media-foundation`: - [~] [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
@@ -267,6 +267,8 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-11] Added a staging deployment execution checklist and deployment-record template to capture first real-host rollout evidence. - [2026-02-11] Added a staging deployment execution checklist and deployment-record template to capture first real-host rollout evidence.
- [2026-02-11] Artist-focused feature map refined: MVP1 covers portfolio media/domain CRUD + announcements + customer/commission linking; MVP2 covers advanced automation (watermark, palette extraction, media transform pipelines). - [2026-02-11] Artist-focused feature map refined: MVP1 covers portfolio media/domain CRUD + announcements + customer/commission linking; MVP2 covers advanced automation (watermark, palette extraction, media transform pipelines).
- [2026-02-11] `gaertan` inspiration to reuse: S3 object strategy with signed delivery, commission type/options/extras/custom-input modeling, request-status kanban mapping, and gallery rendition/color extraction patterns. - [2026-02-11] `gaertan` inspiration to reuse: S3 object strategy with signed delivery, commission type/options/extras/custom-input modeling, request-status kanban mapping, and gallery rendition/color extraction patterns.
- [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.
## How We Use This File ## How We Use This File

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 { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards" import { requirePermissionForRoute } from "@/lib/route-guards"
@@ -10,6 +11,7 @@ export default async function MediaManagementPage() {
permission: "media:read", permission: "media:read",
scope: "team", scope: "team",
}) })
const [summary, assets] = await Promise.all([getMediaFoundationSummary(), listMediaAssets(20)])
return ( return (
<AdminShell <AdminShell
@@ -17,18 +19,70 @@ export default async function MediaManagementPage() {
activePath="/media" activePath="/media"
badge="Admin App" badge="Admin App"
title="Media" title="Media"
description="Prepare media library and enrichment workflows." description="Media foundation baseline for assets, artwork renditions, and grouping metadata."
> >
<AdminSectionPlaceholder <section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
feature="Media Library" <article className="rounded-xl border border-neutral-200 p-4">
summary="This route is ready for media browsing, upload, and metadata refinement features." <p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Media Assets</p>
requiredPermission="media:read (team)" <p className="mt-2 text-3xl font-semibold">{summary.mediaAssets}</p>
nextSteps={[ </article>
"Add media upload and asset listing.", <article className="rounded-xl border border-neutral-200 p-4">
"Add enrichment fields (alt text, source, tags).", <p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Artworks</p>
"Add artwork-specific refinement fields.", <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> </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: "/", label: "Dashboard", permission: "dashboard:read", scope: "global" },
{ href: "/pages", label: "Pages", permission: "pages:read", scope: "team" }, { href: "/pages", label: "Pages", permission: "pages:read", scope: "team" },
{ href: "/media", label: "Media", permission: "media: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: "/users", label: "Users", permission: "users:read", scope: "own" },
{ href: "/commissions", label: "Commissions", permission: "commissions:read", scope: "own" }, { href: "/commissions", label: "Commissions", permission: "commissions:read", scope: "own" },
{ href: "/settings", label: "Settings", permission: "users:manage_roles", scope: "global" }, { href: "/settings", label: "Settings", permission: "users:manage_roles", scope: "global" },

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { z } from "zod" import { z } from "zod"
export * from "./media"
export * from "./rbac" export * from "./rbac"
export const postStatusSchema = z.enum(["draft", "published"]) export const postStatusSchema = z.enum(["draft", "published"])

View File

@@ -0,0 +1,39 @@
import { z } from "zod"
export const mediaAssetTypeSchema = z.enum([
"artwork",
"banner",
"promotion",
"video",
"gif",
"generic",
])
export const artworkRenditionSlotSchema = z.enum(["thumbnail", "card", "full", "custom"])
export const createMediaAssetInputSchema = z.object({
type: mediaAssetTypeSchema,
title: z.string().min(1).max(180),
description: z.string().max(5000).optional(),
altText: z.string().max(1000).optional(),
source: z.string().max(500).optional(),
copyright: z.string().max(500).optional(),
author: z.string().max(180).optional(),
tags: z.array(z.string().min(1).max(100)).default([]),
})
export const createArtworkInputSchema = z.object({
title: z.string().min(1).max(180),
slug: z.string().min(1).max(180),
description: z.string().max(5000).optional(),
medium: z.string().max(180).optional(),
dimensions: z.string().max(180).optional(),
year: z.number().int().min(1000).max(9999).optional(),
framing: z.string().max(180).optional(),
availability: z.string().max(180).optional(),
})
export type MediaAssetType = z.infer<typeof mediaAssetTypeSchema>
export type ArtworkRenditionSlot = z.infer<typeof artworkRenditionSlotSchema>
export type CreateMediaAssetInput = z.infer<typeof createMediaAssetInputSchema>
export type CreateArtworkInput = z.infer<typeof createArtworkInputSchema>

View File

@@ -0,0 +1,235 @@
-- CreateTable
CREATE TABLE "MediaAsset" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"altText" TEXT,
"source" TEXT,
"copyright" TEXT,
"author" TEXT,
"tags" TEXT[],
"storageKey" TEXT,
"mimeType" TEXT,
"width" INTEGER,
"height" INTEGER,
"sizeBytes" INTEGER,
"isPublished" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MediaAsset_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Artwork" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"medium" TEXT,
"dimensions" TEXT,
"year" INTEGER,
"framing" TEXT,
"availability" TEXT,
"isPublished" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Artwork_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ArtworkRendition" (
"id" TEXT NOT NULL,
"artworkId" TEXT NOT NULL,
"mediaAssetId" TEXT NOT NULL,
"slot" TEXT NOT NULL,
"width" INTEGER,
"height" INTEGER,
"isPrimary" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ArtworkRendition_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Gallery" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isVisible" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Gallery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Album" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isVisible" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Album_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isVisible" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ArtworkGallery" (
"id" TEXT NOT NULL,
"artworkId" TEXT NOT NULL,
"galleryId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ArtworkGallery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ArtworkAlbum" (
"id" TEXT NOT NULL,
"artworkId" TEXT NOT NULL,
"albumId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ArtworkAlbum_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ArtworkCategory" (
"id" TEXT NOT NULL,
"artworkId" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ArtworkCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ArtworkTag" (
"id" TEXT NOT NULL,
"artworkId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ArtworkTag_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "MediaAsset_storageKey_key" ON "MediaAsset"("storageKey");
-- CreateIndex
CREATE INDEX "MediaAsset_type_idx" ON "MediaAsset"("type");
-- CreateIndex
CREATE INDEX "MediaAsset_isPublished_idx" ON "MediaAsset"("isPublished");
-- CreateIndex
CREATE UNIQUE INDEX "Artwork_slug_key" ON "Artwork"("slug");
-- CreateIndex
CREATE INDEX "Artwork_isPublished_idx" ON "Artwork"("isPublished");
-- CreateIndex
CREATE INDEX "ArtworkRendition_mediaAssetId_idx" ON "ArtworkRendition"("mediaAssetId");
-- CreateIndex
CREATE UNIQUE INDEX "ArtworkRendition_artworkId_slot_key" ON "ArtworkRendition"("artworkId", "slot");
-- CreateIndex
CREATE UNIQUE INDEX "Gallery_slug_key" ON "Gallery"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Album_slug_key" ON "Album"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Tag_slug_key" ON "Tag"("slug");
-- CreateIndex
CREATE INDEX "ArtworkGallery_galleryId_idx" ON "ArtworkGallery"("galleryId");
-- CreateIndex
CREATE UNIQUE INDEX "ArtworkGallery_artworkId_galleryId_key" ON "ArtworkGallery"("artworkId", "galleryId");
-- CreateIndex
CREATE INDEX "ArtworkAlbum_albumId_idx" ON "ArtworkAlbum"("albumId");
-- CreateIndex
CREATE UNIQUE INDEX "ArtworkAlbum_artworkId_albumId_key" ON "ArtworkAlbum"("artworkId", "albumId");
-- CreateIndex
CREATE INDEX "ArtworkCategory_categoryId_idx" ON "ArtworkCategory"("categoryId");
-- CreateIndex
CREATE UNIQUE INDEX "ArtworkCategory_artworkId_categoryId_key" ON "ArtworkCategory"("artworkId", "categoryId");
-- CreateIndex
CREATE INDEX "ArtworkTag_tagId_idx" ON "ArtworkTag"("tagId");
-- CreateIndex
CREATE UNIQUE INDEX "ArtworkTag_artworkId_tagId_key" ON "ArtworkTag"("artworkId", "tagId");
-- AddForeignKey
ALTER TABLE "ArtworkRendition" ADD CONSTRAINT "ArtworkRendition_artworkId_fkey" FOREIGN KEY ("artworkId") REFERENCES "Artwork"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkRendition" ADD CONSTRAINT "ArtworkRendition_mediaAssetId_fkey" FOREIGN KEY ("mediaAssetId") REFERENCES "MediaAsset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkGallery" ADD CONSTRAINT "ArtworkGallery_artworkId_fkey" FOREIGN KEY ("artworkId") REFERENCES "Artwork"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkGallery" ADD CONSTRAINT "ArtworkGallery_galleryId_fkey" FOREIGN KEY ("galleryId") REFERENCES "Gallery"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkAlbum" ADD CONSTRAINT "ArtworkAlbum_artworkId_fkey" FOREIGN KEY ("artworkId") REFERENCES "Artwork"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkAlbum" ADD CONSTRAINT "ArtworkAlbum_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkCategory" ADD CONSTRAINT "ArtworkCategory_artworkId_fkey" FOREIGN KEY ("artworkId") REFERENCES "Artwork"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkCategory" ADD CONSTRAINT "ArtworkCategory_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkTag" ADD CONSTRAINT "ArtworkTag_artworkId_fkey" FOREIGN KEY ("artworkId") REFERENCES "Artwork"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtworkTag" ADD CONSTRAINT "ArtworkTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,5 +1,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client"
output = "./generated/client"
} }
datasource db { datasource db {
@@ -95,3 +96,159 @@ model SystemSetting {
@@map("system_setting") @@map("system_setting")
} }
model MediaAsset {
id String @id @default(uuid())
type String
title String
description String?
altText String?
source String?
copyright String?
author String?
tags String[]
storageKey String? @unique
mimeType String?
width Int?
height Int?
sizeBytes Int?
isPublished Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
artworkLinks ArtworkRendition[]
@@index([type])
@@index([isPublished])
}
model Artwork {
id String @id @default(uuid())
title String
slug String @unique
description String?
medium String?
dimensions String?
year Int?
framing String?
availability String?
isPublished Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
renditions ArtworkRendition[]
galleryLinks ArtworkGallery[]
albumLinks ArtworkAlbum[]
categoryLinks ArtworkCategory[]
tagLinks ArtworkTag[]
@@index([isPublished])
}
model ArtworkRendition {
id String @id @default(uuid())
artworkId String
mediaAssetId String
slot String
width Int?
height Int?
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
artwork Artwork @relation(fields: [artworkId], references: [id], onDelete: Cascade)
mediaAsset MediaAsset @relation(fields: [mediaAssetId], references: [id], onDelete: Cascade)
@@unique([artworkId, slot])
@@index([mediaAssetId])
}
model Gallery {
id String @id @default(uuid())
name String
slug String @unique
description String?
sortOrder Int @default(0)
isVisible Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
artworkLinks ArtworkGallery[]
}
model Album {
id String @id @default(uuid())
name String
slug String @unique
description String?
sortOrder Int @default(0)
isVisible Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
artworkLinks ArtworkAlbum[]
}
model Category {
id String @id @default(uuid())
name String
slug String @unique
description String?
sortOrder Int @default(0)
isVisible Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
artworkLinks ArtworkCategory[]
}
model Tag {
id String @id @default(uuid())
name String
slug String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
artworkLinks ArtworkTag[]
}
model ArtworkGallery {
id String @id @default(uuid())
artworkId String
galleryId String
createdAt DateTime @default(now())
artwork Artwork @relation(fields: [artworkId], references: [id], onDelete: Cascade)
gallery Gallery @relation(fields: [galleryId], references: [id], onDelete: Cascade)
@@unique([artworkId, galleryId])
@@index([galleryId])
}
model ArtworkAlbum {
id String @id @default(uuid())
artworkId String
albumId String
createdAt DateTime @default(now())
artwork Artwork @relation(fields: [artworkId], references: [id], onDelete: Cascade)
album Album @relation(fields: [albumId], references: [id], onDelete: Cascade)
@@unique([artworkId, albumId])
@@index([albumId])
}
model ArtworkCategory {
id String @id @default(uuid())
artworkId String
categoryId String
createdAt DateTime @default(now())
artwork Artwork @relation(fields: [artworkId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@unique([artworkId, categoryId])
@@index([categoryId])
}
model ArtworkTag {
id String @id @default(uuid())
artworkId String
tagId String
createdAt DateTime @default(now())
artwork Artwork @relation(fields: [artworkId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@unique([artworkId, tagId])
@@index([tagId])
}

View File

@@ -1,6 +1,6 @@
import { PrismaPg } from "@prisma/adapter-pg" import { PrismaPg } from "@prisma/adapter-pg"
import { PrismaClient } from "@prisma/client"
import { Pool } from "pg" import { Pool } from "pg"
import { PrismaClient } from "../prisma/generated/client/client"
const connectionString = process.env.DATABASE_URL const connectionString = process.env.DATABASE_URL

View File

@@ -1,4 +1,5 @@
export { db } from "./client" export { db } from "./client"
export { getMediaFoundationSummary, listArtworks, listMediaAssets } from "./media-foundation"
export { export {
createPost, createPost,
deletePost, deletePost,

View File

@@ -0,0 +1,88 @@
import { db } from "./client"
export async function listMediaAssets(limit = 24) {
return db.mediaAsset.findMany({
orderBy: { updatedAt: "desc" },
take: limit,
})
}
export async function listArtworks(limit = 24) {
return db.artwork.findMany({
orderBy: { updatedAt: "desc" },
take: limit,
include: {
renditions: {
select: {
id: true,
slot: true,
mediaAssetId: true,
},
},
galleryLinks: {
include: {
gallery: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
albumLinks: {
include: {
album: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
categoryLinks: {
include: {
category: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
tagLinks: {
include: {
tag: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
},
})
}
export async function getMediaFoundationSummary() {
const [mediaAssets, artworks, galleries, albums, categories, tags] = await Promise.all([
db.mediaAsset.count(),
db.artwork.count(),
db.gallery.count(),
db.album.count(),
db.category.count(),
db.tag.count(),
])
return {
mediaAssets,
artworks,
galleries,
albums,
categories,
tags,
}
}

View File

@@ -5,7 +5,7 @@ import {
updatePostInputSchema, updatePostInputSchema,
} from "@cms/content" } from "@cms/content"
import { type CrudAuditHook, type CrudMutationContext, createCrudService } from "@cms/crud" import { type CrudAuditHook, type CrudMutationContext, createCrudService } from "@cms/crud"
import type { Post } from "@prisma/client" import type { Post } from "../prisma/generated/client/client"
import { db } from "./client" import { db } from "./client"