feat(media): scaffold mvp1 media and portfolio foundation
This commit is contained in:
4
TODO.md
4
TODO.md
@@ -118,7 +118,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
|
||||
### 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
|
||||
- [ ] [P1] `todo/mvp1-media-upload-pipeline`:
|
||||
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] 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] 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
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
69
apps/admin/src/app/portfolio/page.tsx
Normal file
69
apps/admin/src/app/portfolio/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -57,6 +57,13 @@ const guardRules: GuardRule[] = [
|
||||
scope: "team",
|
||||
},
|
||||
},
|
||||
{
|
||||
route: /^\/portfolio(?:\/|$)/,
|
||||
requirement: {
|
||||
permission: "media:read",
|
||||
scope: "team",
|
||||
},
|
||||
},
|
||||
{
|
||||
route: /^\/users(?:\/|$)/,
|
||||
requirement: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export * from "./media"
|
||||
export * from "./rbac"
|
||||
|
||||
export const postStatusSchema = z.enum(["draft", "published"])
|
||||
|
||||
39
packages/content/src/media.ts
Normal file
39
packages/content/src/media.ts
Normal 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>
|
||||
@@ -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;
|
||||
@@ -1,5 +1,6 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client"
|
||||
output = "./generated/client"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -95,3 +96,159 @@ model SystemSetting {
|
||||
|
||||
@@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])
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PrismaPg } from "@prisma/adapter-pg"
|
||||
import { PrismaClient } from "@prisma/client"
|
||||
import { Pool } from "pg"
|
||||
import { PrismaClient } from "../prisma/generated/client/client"
|
||||
|
||||
const connectionString = process.env.DATABASE_URL
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { db } from "./client"
|
||||
export { getMediaFoundationSummary, listArtworks, listMediaAssets } from "./media-foundation"
|
||||
export {
|
||||
createPost,
|
||||
deletePost,
|
||||
|
||||
88
packages/db/src/media-foundation.ts
Normal file
88
packages/db/src/media-foundation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
updatePostInputSchema,
|
||||
} from "@cms/content"
|
||||
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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user