feat(media): complete mvp1 media foundation workflows
This commit is contained in:
3
TODO.md
3
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`:
|
- [x] [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
|
||||||
@@ -269,6 +269,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
- [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] 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.
|
- [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.
|
||||||
|
- [2026-02-11] MVP1 media foundation now includes baseline create/link workflows in admin (`/media`, `/portfolio`), seeded sample portfolio entities, and schema/service test coverage.
|
||||||
|
|
||||||
## How We Use This File
|
## How We Use This File
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,109 @@
|
|||||||
import { getMediaFoundationSummary, listMediaAssets } from "@cms/db"
|
import { createMediaAsset, getMediaFoundationSummary, listMediaAssets } from "@cms/db"
|
||||||
|
import { Button } from "@cms/ui/button"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
import { AdminShell } from "@/components/admin-shell"
|
import { AdminShell } from "@/components/admin-shell"
|
||||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default async function MediaManagementPage() {
|
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||||
|
|
||||||
|
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
return value ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readField(formData: FormData, field: string): string {
|
||||||
|
const value = formData.get(field)
|
||||||
|
return typeof value === "string" ? value.trim() : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalField(formData: FormData, field: string): string | undefined {
|
||||||
|
const value = readField(formData, field)
|
||||||
|
return value.length > 0 ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTags(formData: FormData, field: string): string[] {
|
||||||
|
const raw = readField(formData, field)
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectWithState(params: { notice?: string; error?: string }) {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
|
||||||
|
if (params.notice) {
|
||||||
|
query.set("notice", params.notice)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.error) {
|
||||||
|
query.set("error", params.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = query.toString()
|
||||||
|
redirect(value ? `/media?${value}` : "/media")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMediaAssetAction(formData: FormData) {
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
await requirePermissionForRoute({
|
||||||
|
nextPath: "/media",
|
||||||
|
permission: "media:write",
|
||||||
|
scope: "team",
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createMediaAsset({
|
||||||
|
title: readField(formData, "title"),
|
||||||
|
type: readField(formData, "type"),
|
||||||
|
description: readOptionalField(formData, "description"),
|
||||||
|
altText: readOptionalField(formData, "altText"),
|
||||||
|
source: readOptionalField(formData, "source"),
|
||||||
|
copyright: readOptionalField(formData, "copyright"),
|
||||||
|
author: readOptionalField(formData, "author"),
|
||||||
|
tags: readTags(formData, "tags"),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
redirectWithState({
|
||||||
|
error: "Failed to create media asset. Validate required fields and try again.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/media")
|
||||||
|
revalidatePath("/portfolio")
|
||||||
|
redirectWithState({ notice: "Media asset created." })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MediaManagementPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParamsInput>
|
||||||
|
}) {
|
||||||
const role = await requirePermissionForRoute({
|
const role = await requirePermissionForRoute({
|
||||||
nextPath: "/media",
|
nextPath: "/media",
|
||||||
permission: "media:read",
|
permission: "media:read",
|
||||||
scope: "team",
|
scope: "team",
|
||||||
})
|
})
|
||||||
const [summary, assets] = await Promise.all([getMediaFoundationSummary(), listMediaAssets(20)])
|
const [resolvedSearchParams, summary, assets] = await Promise.all([
|
||||||
|
searchParams,
|
||||||
|
getMediaFoundationSummary(),
|
||||||
|
listMediaAssets(20),
|
||||||
|
])
|
||||||
|
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||||
|
const error = readFirstValue(resolvedSearchParams.error)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
@@ -21,6 +113,18 @@ export default async function MediaManagementPage() {
|
|||||||
title="Media"
|
title="Media"
|
||||||
description="Media foundation baseline for assets, artwork renditions, and grouping metadata."
|
description="Media foundation baseline for assets, artwork renditions, and grouping metadata."
|
||||||
>
|
>
|
||||||
|
{notice ? (
|
||||||
|
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||||
|
{notice}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<article className="rounded-xl border border-neutral-200 p-4">
|
<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="text-xs uppercase tracking-[0.2em] text-neutral-500">Media Assets</p>
|
||||||
@@ -43,6 +147,86 @@ export default async function MediaManagementPage() {
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-neutral-200 p-6">
|
||||||
|
<h2 className="text-xl font-medium">Create Media Asset</h2>
|
||||||
|
<form action={createMediaAssetAction} className="mt-4 space-y-3">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-neutral-600">Title</span>
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
minLength={1}
|
||||||
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-neutral-600">Type</span>
|
||||||
|
<select
|
||||||
|
name="type"
|
||||||
|
defaultValue="artwork"
|
||||||
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="artwork">artwork</option>
|
||||||
|
<option value="banner">banner</option>
|
||||||
|
<option value="promotion">promotion</option>
|
||||||
|
<option value="video">video</option>
|
||||||
|
<option value="gif">gif</option>
|
||||||
|
<option value="generic">generic</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-neutral-600">Description</span>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-neutral-600">Alt text</span>
|
||||||
|
<input
|
||||||
|
name="altText"
|
||||||
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-neutral-600">Author</span>
|
||||||
|
<input
|
||||||
|
name="author"
|
||||||
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-neutral-600">Source</span>
|
||||||
|
<input
|
||||||
|
name="source"
|
||||||
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-neutral-600">Copyright</span>
|
||||||
|
<input
|
||||||
|
name="copyright"
|
||||||
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-neutral-600">Tags (comma-separated)</span>
|
||||||
|
<input
|
||||||
|
name="tags"
|
||||||
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button type="submit">Create media asset</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-neutral-200 p-6">
|
<section className="rounded-xl border border-neutral-200 p-6">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h2 className="text-xl font-medium">Recent Media Assets</h2>
|
<h2 className="text-xl font-medium">Recent Media Assets</h2>
|
||||||
|
|||||||
@@ -1,17 +1,218 @@
|
|||||||
import { listArtworks } from "@cms/db"
|
import {
|
||||||
|
attachArtworkRendition,
|
||||||
|
createAlbum,
|
||||||
|
createArtwork,
|
||||||
|
createCategory,
|
||||||
|
createGallery,
|
||||||
|
createTag,
|
||||||
|
linkArtworkToGrouping,
|
||||||
|
listArtworks,
|
||||||
|
listMediaAssets,
|
||||||
|
listMediaFoundationGroups,
|
||||||
|
} from "@cms/db"
|
||||||
|
import { Button } from "@cms/ui/button"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
import { AdminShell } from "@/components/admin-shell"
|
import { AdminShell } from "@/components/admin-shell"
|
||||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default async function PortfolioPage() {
|
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||||
|
type GroupType = "gallery" | "album" | "category" | "tag"
|
||||||
|
|
||||||
|
function readField(formData: FormData, key: string): string {
|
||||||
|
const value = formData.get(key)
|
||||||
|
return typeof value === "string" ? value.trim() : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalField(formData: FormData, key: string): string | undefined {
|
||||||
|
const value = readField(formData, key)
|
||||||
|
return value.length > 0 ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
return value ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(input: string): string {
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectWithState(params: { notice?: string; error?: string }) {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
|
||||||
|
if (params.notice) {
|
||||||
|
query.set("notice", params.notice)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.error) {
|
||||||
|
query.set("error", params.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = query.toString()
|
||||||
|
redirect(value ? `/portfolio?${value}` : "/portfolio")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireWritePermission() {
|
||||||
|
await requirePermissionForRoute({
|
||||||
|
nextPath: "/portfolio",
|
||||||
|
permission: "media:write",
|
||||||
|
scope: "team",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createArtworkAction(formData: FormData) {
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
await requireWritePermission()
|
||||||
|
|
||||||
|
const title = readField(formData, "title")
|
||||||
|
const slug = slugify(readField(formData, "slug") || title)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createArtwork({
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
description: readOptionalField(formData, "description"),
|
||||||
|
medium: readOptionalField(formData, "medium"),
|
||||||
|
dimensions: readOptionalField(formData, "dimensions"),
|
||||||
|
framing: readOptionalField(formData, "framing"),
|
||||||
|
availability: readOptionalField(formData, "availability"),
|
||||||
|
year: (() => {
|
||||||
|
const raw = readField(formData, "year")
|
||||||
|
return raw ? Number(raw) : undefined
|
||||||
|
})(),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
redirectWithState({ error: "Failed to create artwork." })
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/portfolio")
|
||||||
|
redirectWithState({ notice: "Artwork created." })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGroupAction(formData: FormData) {
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
await requireWritePermission()
|
||||||
|
|
||||||
|
const type = readField(formData, "groupType") as GroupType
|
||||||
|
const name = readField(formData, "name")
|
||||||
|
const slug = slugify(readField(formData, "slug") || name)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (type === "gallery") {
|
||||||
|
await createGallery({
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description: readOptionalField(formData, "description"),
|
||||||
|
})
|
||||||
|
} else if (type === "album") {
|
||||||
|
await createAlbum({
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description: readOptionalField(formData, "description"),
|
||||||
|
})
|
||||||
|
} else if (type === "category") {
|
||||||
|
await createCategory({
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description: readOptionalField(formData, "description"),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createTag({
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
redirectWithState({ error: "Failed to create grouping entity." })
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/portfolio")
|
||||||
|
redirectWithState({ notice: `${type} created.` })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkArtworkGroupAction(formData: FormData) {
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
await requireWritePermission()
|
||||||
|
|
||||||
|
const artworkId = readField(formData, "artworkId")
|
||||||
|
const groupType = readField(formData, "groupType") as GroupType
|
||||||
|
const groupId = readField(formData, "groupId")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await linkArtworkToGrouping({
|
||||||
|
artworkId,
|
||||||
|
groupType,
|
||||||
|
groupId,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
redirectWithState({ error: "Failed to link artwork to grouping." })
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/portfolio")
|
||||||
|
redirectWithState({ notice: "Artwork linked to grouping." })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachRenditionAction(formData: FormData) {
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
await requireWritePermission()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await attachArtworkRendition({
|
||||||
|
artworkId: readField(formData, "artworkId"),
|
||||||
|
mediaAssetId: readField(formData, "mediaAssetId"),
|
||||||
|
slot: readField(formData, "slot"),
|
||||||
|
width: (() => {
|
||||||
|
const raw = readField(formData, "width")
|
||||||
|
return raw ? Number(raw) : undefined
|
||||||
|
})(),
|
||||||
|
height: (() => {
|
||||||
|
const raw = readField(formData, "height")
|
||||||
|
return raw ? Number(raw) : undefined
|
||||||
|
})(),
|
||||||
|
isPrimary: readField(formData, "isPrimary") === "true",
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
redirectWithState({ error: "Failed to attach artwork rendition." })
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/portfolio")
|
||||||
|
redirectWithState({ notice: "Rendition attached." })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PortfolioPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParamsInput>
|
||||||
|
}) {
|
||||||
const role = await requirePermissionForRoute({
|
const role = await requirePermissionForRoute({
|
||||||
nextPath: "/portfolio",
|
nextPath: "/portfolio",
|
||||||
permission: "media:read",
|
permission: "media:read",
|
||||||
scope: "team",
|
scope: "team",
|
||||||
})
|
})
|
||||||
const artworks = await listArtworks(30)
|
const [resolvedSearchParams, artworks, mediaAssets, groups] = await Promise.all([
|
||||||
|
searchParams,
|
||||||
|
listArtworks(30),
|
||||||
|
listMediaAssets(200),
|
||||||
|
listMediaFoundationGroups(),
|
||||||
|
])
|
||||||
|
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||||
|
const error = readFirstValue(resolvedSearchParams.error)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
@@ -21,6 +222,217 @@ export default async function PortfolioPage() {
|
|||||||
title="Portfolio"
|
title="Portfolio"
|
||||||
description="Artwork foundation with rendition slots and grouping relations."
|
description="Artwork foundation with rendition slots and grouping relations."
|
||||||
>
|
>
|
||||||
|
{notice ? (
|
||||||
|
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||||
|
{notice}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-neutral-200 p-6">
|
||||||
|
<h2 className="text-xl font-medium">Create Artwork</h2>
|
||||||
|
<form action={createArtworkAction} className="mt-4 space-y-3">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-neutral-600">Title</span>
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-neutral-600">Slug (optional)</span>
|
||||||
|
<input
|
||||||
|
name="slug"
|
||||||
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-neutral-600">Description</span>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<input
|
||||||
|
name="medium"
|
||||||
|
placeholder="Medium"
|
||||||
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="dimensions"
|
||||||
|
placeholder="Dimensions"
|
||||||
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="year"
|
||||||
|
type="number"
|
||||||
|
placeholder="Year"
|
||||||
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="framing"
|
||||||
|
placeholder="Framing"
|
||||||
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
name="availability"
|
||||||
|
placeholder="Availability"
|
||||||
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<Button type="submit">Create artwork</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-neutral-200 p-6">
|
||||||
|
<h2 className="text-xl font-medium">Create Group Entity</h2>
|
||||||
|
<form action={createGroupAction} className="mt-4 space-y-3">
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<select
|
||||||
|
name="groupType"
|
||||||
|
defaultValue="gallery"
|
||||||
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="gallery">gallery</option>
|
||||||
|
<option value="album">album</option>
|
||||||
|
<option value="category">category</option>
|
||||||
|
<option value="tag">tag</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
placeholder="Name"
|
||||||
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="slug"
|
||||||
|
placeholder="Slug (optional)"
|
||||||
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<Button type="submit">Create group</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-neutral-200 p-6">
|
||||||
|
<h2 className="text-xl font-medium">Link Artwork To Group</h2>
|
||||||
|
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ type: "gallery" as const, label: "Gallery", items: groups.galleries },
|
||||||
|
{ type: "album" as const, label: "Album", items: groups.albums },
|
||||||
|
{ type: "category" as const, label: "Category", items: groups.categories },
|
||||||
|
{ type: "tag" as const, label: "Tag", items: groups.tags },
|
||||||
|
] as const
|
||||||
|
).map((groupConfig) => (
|
||||||
|
<form
|
||||||
|
key={groupConfig.type}
|
||||||
|
action={linkArtworkGroupAction}
|
||||||
|
className="space-y-3 rounded border border-neutral-200 p-4"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold">{groupConfig.label} Link</h3>
|
||||||
|
<input type="hidden" name="groupType" value={groupConfig.type} />
|
||||||
|
<select
|
||||||
|
name="artworkId"
|
||||||
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{artworks.map((artwork) => (
|
||||||
|
<option key={`${groupConfig.type}-${artwork.id}`} value={artwork.id}>
|
||||||
|
{artwork.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
name="groupId"
|
||||||
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{groupConfig.items.map((group) => (
|
||||||
|
<option key={`${groupConfig.type}-${group.id}`} value={group.id}>
|
||||||
|
{group.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button type="submit">Link artwork</Button>
|
||||||
|
</form>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-neutral-200 p-6">
|
||||||
|
<h2 className="text-xl font-medium">Attach Artwork Rendition Slot</h2>
|
||||||
|
<form
|
||||||
|
action={attachRenditionAction}
|
||||||
|
className="mt-4 grid gap-3 md:grid-cols-3 xl:grid-cols-6"
|
||||||
|
>
|
||||||
|
<select name="artworkId" className="rounded border border-neutral-300 px-3 py-2 text-sm">
|
||||||
|
{artworks.map((artwork) => (
|
||||||
|
<option key={artwork.id} value={artwork.id}>
|
||||||
|
{artwork.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
name="mediaAssetId"
|
||||||
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{mediaAssets.map((asset) => (
|
||||||
|
<option key={asset.id} value={asset.id}>
|
||||||
|
{asset.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
name="slot"
|
||||||
|
defaultValue="thumbnail"
|
||||||
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="thumbnail">thumbnail</option>
|
||||||
|
<option value="card">card</option>
|
||||||
|
<option value="full">full</option>
|
||||||
|
<option value="custom">custom</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
name="width"
|
||||||
|
type="number"
|
||||||
|
placeholder="width"
|
||||||
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="height"
|
||||||
|
type="number"
|
||||||
|
placeholder="height"
|
||||||
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
name="isPrimary"
|
||||||
|
defaultValue="false"
|
||||||
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="false">not primary</option>
|
||||||
|
<option value="true">primary</option>
|
||||||
|
</select>
|
||||||
|
<div className="md:col-span-3 xl:col-span-6">
|
||||||
|
<Button type="submit">Attach rendition</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-neutral-200 p-6">
|
<section className="rounded-xl border border-neutral-200 p-6">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h2 className="text-xl font-medium">Artworks</h2>
|
<h2 className="text-xl font-medium">Artworks</h2>
|
||||||
|
|||||||
51
packages/content/src/media.test.ts
Normal file
51
packages/content/src/media.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import {
|
||||||
|
attachArtworkRenditionInputSchema,
|
||||||
|
createGroupingInputSchema,
|
||||||
|
createMediaAssetInputSchema,
|
||||||
|
linkArtworkGroupingInputSchema,
|
||||||
|
} from "./media"
|
||||||
|
|
||||||
|
describe("media schemas", () => {
|
||||||
|
it("accepts supported media asset type payload", () => {
|
||||||
|
const parsed = createMediaAssetInputSchema.parse({
|
||||||
|
type: "artwork",
|
||||||
|
title: "Artwork",
|
||||||
|
tags: ["tag-a"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(parsed.type).toBe("artwork")
|
||||||
|
expect(parsed.tags).toEqual(["tag-a"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("validates grouping link payload", () => {
|
||||||
|
const parsed = linkArtworkGroupingInputSchema.parse({
|
||||||
|
artworkId: "f40f4bcc-7148-45d7-a19d-856f7146a47e",
|
||||||
|
groupType: "gallery",
|
||||||
|
groupId: "f4e094df-0edf-4d5a-8b7b-c51f09cae95e",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(parsed.groupType).toBe("gallery")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("enforces rendition slot enum", () => {
|
||||||
|
const parsed = attachArtworkRenditionInputSchema.parse({
|
||||||
|
artworkId: "f40f4bcc-7148-45d7-a19d-856f7146a47e",
|
||||||
|
mediaAssetId: "f4e094df-0edf-4d5a-8b7b-c51f09cae95e",
|
||||||
|
slot: "thumbnail",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(parsed.slot).toBe("thumbnail")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("supports grouping defaults", () => {
|
||||||
|
const parsed = createGroupingInputSchema.parse({
|
||||||
|
name: "Featured",
|
||||||
|
slug: "featured",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(parsed.sortOrder).toBe(0)
|
||||||
|
expect(parsed.isVisible).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -33,7 +33,33 @@ export const createArtworkInputSchema = z.object({
|
|||||||
availability: z.string().max(180).optional(),
|
availability: z.string().max(180).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const createGroupingInputSchema = z.object({
|
||||||
|
name: z.string().min(1).max(180),
|
||||||
|
slug: z.string().min(1).max(180),
|
||||||
|
description: z.string().max(5000).optional(),
|
||||||
|
sortOrder: z.number().int().min(0).default(0),
|
||||||
|
isVisible: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const linkArtworkGroupingInputSchema = z.object({
|
||||||
|
artworkId: z.string().uuid(),
|
||||||
|
groupType: z.enum(["gallery", "album", "category", "tag"]),
|
||||||
|
groupId: z.string().uuid(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const attachArtworkRenditionInputSchema = z.object({
|
||||||
|
artworkId: z.string().uuid(),
|
||||||
|
mediaAssetId: z.string().uuid(),
|
||||||
|
slot: artworkRenditionSlotSchema,
|
||||||
|
width: z.number().int().positive().optional(),
|
||||||
|
height: z.number().int().positive().optional(),
|
||||||
|
isPrimary: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
|
||||||
export type MediaAssetType = z.infer<typeof mediaAssetTypeSchema>
|
export type MediaAssetType = z.infer<typeof mediaAssetTypeSchema>
|
||||||
export type ArtworkRenditionSlot = z.infer<typeof artworkRenditionSlotSchema>
|
export type ArtworkRenditionSlot = z.infer<typeof artworkRenditionSlotSchema>
|
||||||
export type CreateMediaAssetInput = z.infer<typeof createMediaAssetInputSchema>
|
export type CreateMediaAssetInput = z.infer<typeof createMediaAssetInputSchema>
|
||||||
export type CreateArtworkInput = z.infer<typeof createArtworkInputSchema>
|
export type CreateArtworkInput = z.infer<typeof createArtworkInputSchema>
|
||||||
|
export type CreateGroupingInput = z.infer<typeof createGroupingInputSchema>
|
||||||
|
export type LinkArtworkGroupingInput = z.infer<typeof linkArtworkGroupingInputSchema>
|
||||||
|
export type AttachArtworkRenditionInput = z.infer<typeof attachArtworkRenditionInputSchema>
|
||||||
|
|||||||
@@ -13,6 +13,75 @@ async function main() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const media = await db.mediaAsset.upsert({
|
||||||
|
where: { storageKey: "seed/artwork-welcome.jpg" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
type: "artwork",
|
||||||
|
title: "Seed Artwork Image",
|
||||||
|
altText: "Seed artwork placeholder",
|
||||||
|
tags: ["seed", "portfolio"],
|
||||||
|
storageKey: "seed/artwork-welcome.jpg",
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
isPublished: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const artwork = await db.artwork.upsert({
|
||||||
|
where: { slug: "seed-artwork-welcome" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
title: "Seed Artwork",
|
||||||
|
slug: "seed-artwork-welcome",
|
||||||
|
description: "Baseline seeded artwork for MVP1 media foundation.",
|
||||||
|
medium: "Digital",
|
||||||
|
year: 2026,
|
||||||
|
availability: "available",
|
||||||
|
isPublished: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const gallery = await db.gallery.upsert({
|
||||||
|
where: { slug: "featured" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: "Featured",
|
||||||
|
slug: "featured",
|
||||||
|
description: "Featured artwork selection.",
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.artworkGallery.upsert({
|
||||||
|
where: {
|
||||||
|
artworkId_galleryId: {
|
||||||
|
artworkId: artwork.id,
|
||||||
|
galleryId: gallery.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
artworkId: artwork.id,
|
||||||
|
galleryId: gallery.id,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.artworkRendition.upsert({
|
||||||
|
where: {
|
||||||
|
artworkId_slot: {
|
||||||
|
artworkId: artwork.id,
|
||||||
|
slot: "thumbnail",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
artworkId: artwork.id,
|
||||||
|
mediaAssetId: media.id,
|
||||||
|
slot: "thumbnail",
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
})
|
||||||
|
|
||||||
await db.systemSetting.upsert({
|
await db.systemSetting.upsert({
|
||||||
where: { key: "public.header_banner" },
|
where: { key: "public.header_banner" },
|
||||||
update: {},
|
update: {},
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
export { db } from "./client"
|
export { db } from "./client"
|
||||||
export { getMediaFoundationSummary, listArtworks, listMediaAssets } from "./media-foundation"
|
export {
|
||||||
|
attachArtworkRendition,
|
||||||
|
createAlbum,
|
||||||
|
createArtwork,
|
||||||
|
createCategory,
|
||||||
|
createGallery,
|
||||||
|
createMediaAsset,
|
||||||
|
createTag,
|
||||||
|
getMediaFoundationSummary,
|
||||||
|
linkArtworkToGrouping,
|
||||||
|
listArtworks,
|
||||||
|
listMediaAssets,
|
||||||
|
listMediaFoundationGroups,
|
||||||
|
} from "./media-foundation"
|
||||||
export {
|
export {
|
||||||
createPost,
|
createPost,
|
||||||
deletePost,
|
deletePost,
|
||||||
|
|||||||
93
packages/db/src/media-foundation.test.ts
Normal file
93
packages/db/src/media-foundation.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
const { mockDb } = vi.hoisted(() => ({
|
||||||
|
mockDb: {
|
||||||
|
artworkGallery: { upsert: vi.fn() },
|
||||||
|
artworkAlbum: { upsert: vi.fn() },
|
||||||
|
artworkCategory: { upsert: vi.fn() },
|
||||||
|
artworkTag: { upsert: vi.fn() },
|
||||||
|
artworkRendition: { upsert: vi.fn() },
|
||||||
|
mediaAsset: { create: vi.fn() },
|
||||||
|
artwork: { create: vi.fn() },
|
||||||
|
gallery: { create: vi.fn() },
|
||||||
|
album: { create: vi.fn() },
|
||||||
|
category: { create: vi.fn() },
|
||||||
|
tag: { create: vi.fn() },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("./client", () => ({
|
||||||
|
db: mockDb,
|
||||||
|
}))
|
||||||
|
|
||||||
|
import {
|
||||||
|
attachArtworkRendition,
|
||||||
|
createArtwork,
|
||||||
|
createMediaAsset,
|
||||||
|
linkArtworkToGrouping,
|
||||||
|
} from "./media-foundation"
|
||||||
|
|
||||||
|
describe("media foundation service", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const value of Object.values(mockDb)) {
|
||||||
|
if ("upsert" in value) {
|
||||||
|
value.upsert.mockReset()
|
||||||
|
}
|
||||||
|
if ("create" in value) {
|
||||||
|
value.create.mockReset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("routes grouping links to the correct link table", async () => {
|
||||||
|
mockDb.artworkAlbum.upsert.mockResolvedValue({ id: "link" })
|
||||||
|
|
||||||
|
await linkArtworkToGrouping({
|
||||||
|
artworkId: "f40f4bcc-7148-45d7-a19d-856f7146a47e",
|
||||||
|
groupType: "album",
|
||||||
|
groupId: "f4e094df-0edf-4d5a-8b7b-c51f09cae95e",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockDb.artworkAlbum.upsert).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockDb.artworkGallery.upsert).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("upserts rendition by artwork and slot", async () => {
|
||||||
|
mockDb.artworkRendition.upsert.mockResolvedValue({ id: "rendition" })
|
||||||
|
|
||||||
|
await attachArtworkRendition({
|
||||||
|
artworkId: "f40f4bcc-7148-45d7-a19d-856f7146a47e",
|
||||||
|
mediaAssetId: "f4e094df-0edf-4d5a-8b7b-c51f09cae95e",
|
||||||
|
slot: "thumbnail",
|
||||||
|
isPrimary: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockDb.artworkRendition.upsert).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockDb.artworkRendition.upsert.mock.calls[0]?.[0]).toMatchObject({
|
||||||
|
where: {
|
||||||
|
artworkId_slot: {
|
||||||
|
artworkId: "f40f4bcc-7148-45d7-a19d-856f7146a47e",
|
||||||
|
slot: "thumbnail",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses and forwards media and artwork creation payloads", async () => {
|
||||||
|
mockDb.mediaAsset.create.mockResolvedValue({ id: "asset" })
|
||||||
|
mockDb.artwork.create.mockResolvedValue({ id: "artwork" })
|
||||||
|
|
||||||
|
await createMediaAsset({
|
||||||
|
type: "generic",
|
||||||
|
title: "Asset",
|
||||||
|
tags: [],
|
||||||
|
})
|
||||||
|
await createArtwork({
|
||||||
|
title: "Artwork",
|
||||||
|
slug: "artwork",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockDb.mediaAsset.create).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockDb.artwork.create).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import {
|
||||||
|
attachArtworkRenditionInputSchema,
|
||||||
|
createArtworkInputSchema,
|
||||||
|
createGroupingInputSchema,
|
||||||
|
createMediaAssetInputSchema,
|
||||||
|
linkArtworkGroupingInputSchema,
|
||||||
|
} from "@cms/content"
|
||||||
|
|
||||||
import { db } from "./client"
|
import { db } from "./client"
|
||||||
|
|
||||||
export async function listMediaAssets(limit = 24) {
|
export async function listMediaAssets(limit = 24) {
|
||||||
@@ -67,6 +75,169 @@ export async function listArtworks(limit = 24) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listMediaFoundationGroups() {
|
||||||
|
const [galleries, albums, categories, tags] = await Promise.all([
|
||||||
|
db.gallery.findMany({
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
|
||||||
|
}),
|
||||||
|
db.album.findMany({
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
|
||||||
|
}),
|
||||||
|
db.category.findMany({
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
|
||||||
|
}),
|
||||||
|
db.tag.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
galleries,
|
||||||
|
albums,
|
||||||
|
categories,
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMediaAsset(input: unknown) {
|
||||||
|
const payload = createMediaAssetInputSchema.parse(input)
|
||||||
|
|
||||||
|
return db.mediaAsset.create({
|
||||||
|
data: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createArtwork(input: unknown) {
|
||||||
|
const payload = createArtworkInputSchema.parse(input)
|
||||||
|
|
||||||
|
return db.artwork.create({
|
||||||
|
data: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGallery(input: unknown) {
|
||||||
|
const payload = createGroupingInputSchema.parse(input)
|
||||||
|
|
||||||
|
return db.gallery.create({
|
||||||
|
data: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAlbum(input: unknown) {
|
||||||
|
const payload = createGroupingInputSchema.parse(input)
|
||||||
|
|
||||||
|
return db.album.create({
|
||||||
|
data: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCategory(input: unknown) {
|
||||||
|
const payload = createGroupingInputSchema.parse(input)
|
||||||
|
|
||||||
|
return db.category.create({
|
||||||
|
data: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTag(input: unknown) {
|
||||||
|
const payload = createGroupingInputSchema
|
||||||
|
.pick({
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
})
|
||||||
|
.parse(input)
|
||||||
|
|
||||||
|
return db.tag.create({
|
||||||
|
data: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function linkArtworkToGrouping(input: unknown) {
|
||||||
|
const payload = linkArtworkGroupingInputSchema.parse(input)
|
||||||
|
|
||||||
|
if (payload.groupType === "gallery") {
|
||||||
|
return db.artworkGallery.upsert({
|
||||||
|
where: {
|
||||||
|
artworkId_galleryId: {
|
||||||
|
artworkId: payload.artworkId,
|
||||||
|
galleryId: payload.groupId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
artworkId: payload.artworkId,
|
||||||
|
galleryId: payload.groupId,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.groupType === "album") {
|
||||||
|
return db.artworkAlbum.upsert({
|
||||||
|
where: {
|
||||||
|
artworkId_albumId: {
|
||||||
|
artworkId: payload.artworkId,
|
||||||
|
albumId: payload.groupId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
artworkId: payload.artworkId,
|
||||||
|
albumId: payload.groupId,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.groupType === "category") {
|
||||||
|
return db.artworkCategory.upsert({
|
||||||
|
where: {
|
||||||
|
artworkId_categoryId: {
|
||||||
|
artworkId: payload.artworkId,
|
||||||
|
categoryId: payload.groupId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
artworkId: payload.artworkId,
|
||||||
|
categoryId: payload.groupId,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.artworkTag.upsert({
|
||||||
|
where: {
|
||||||
|
artworkId_tagId: {
|
||||||
|
artworkId: payload.artworkId,
|
||||||
|
tagId: payload.groupId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
artworkId: payload.artworkId,
|
||||||
|
tagId: payload.groupId,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachArtworkRendition(input: unknown) {
|
||||||
|
const payload = attachArtworkRenditionInputSchema.parse(input)
|
||||||
|
|
||||||
|
return db.artworkRendition.upsert({
|
||||||
|
where: {
|
||||||
|
artworkId_slot: {
|
||||||
|
artworkId: payload.artworkId,
|
||||||
|
slot: payload.slot,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: payload,
|
||||||
|
update: {
|
||||||
|
mediaAssetId: payload.mediaAssetId,
|
||||||
|
width: payload.width,
|
||||||
|
height: payload.height,
|
||||||
|
isPrimary: payload.isPrimary,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function getMediaFoundationSummary() {
|
export async function getMediaFoundationSummary() {
|
||||||
const [mediaAssets, artworks, galleries, albums, categories, tags] = await Promise.all([
|
const [mediaAssets, artworks, galleries, albums, categories, tags] = await Promise.all([
|
||||||
db.mediaAsset.count(),
|
db.mediaAsset.count(),
|
||||||
|
|||||||
Reference in New Issue
Block a user