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
|
### 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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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: "/", 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" },
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
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 {
|
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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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,
|
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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user