Files
old.cms.fellies.org/apps/admin/src/app/portfolio/page.tsx

482 lines
16 KiB
TypeScript

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 { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
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({
nextPath: "/portfolio",
permission: "media:read",
scope: "team",
})
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 (
<AdminShell
role={role}
activePath="/portfolio"
badge="Admin App"
title="Portfolio"
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">
<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>
)
}