861 lines
32 KiB
TypeScript
861 lines
32 KiB
TypeScript
import {
|
|
attachArtworkRendition,
|
|
createAlbum,
|
|
createArtwork,
|
|
createCategory,
|
|
createGallery,
|
|
createTag,
|
|
deleteArtworkRendition,
|
|
deleteGrouping,
|
|
linkArtworkToGrouping,
|
|
listArtworks,
|
|
listMediaAssets,
|
|
listMediaFoundationGroups,
|
|
updateArtwork,
|
|
updateGrouping,
|
|
} 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 readOptionalNullableField(formData: FormData, key: string): string | null {
|
|
const value = readField(formData, key)
|
|
return value.length > 0 ? value : null
|
|
}
|
|
|
|
function readNonNegativeInt(formData: FormData, key: string): number {
|
|
const raw = readField(formData, key)
|
|
const value = Number(raw)
|
|
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0
|
|
}
|
|
|
|
function readOptionalNonNegativeInt(formData: FormData, key: string): number | undefined {
|
|
const raw = readField(formData, key)
|
|
if (!raw) {
|
|
return undefined
|
|
}
|
|
const value = Number(raw)
|
|
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined
|
|
}
|
|
|
|
function readBooleanField(formData: FormData, key: string): boolean {
|
|
return formData.get(key) === "on" || readField(formData, key) === "true"
|
|
}
|
|
|
|
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"),
|
|
priceAmountCents: (() => {
|
|
const raw = readField(formData, "priceAmount")
|
|
return raw ? Math.round(Number(raw) * 100) : undefined
|
|
})(),
|
|
priceCurrency: (() => {
|
|
const raw = readField(formData, "priceCurrency").toUpperCase()
|
|
return raw.length === 3 ? raw : undefined
|
|
})(),
|
|
isPriceVisible: readBooleanField(formData, "isPriceVisible"),
|
|
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 updateArtworkAction(formData: FormData) {
|
|
"use server"
|
|
|
|
await requireWritePermission()
|
|
|
|
try {
|
|
await updateArtwork({
|
|
id: readField(formData, "artworkId"),
|
|
medium: readOptionalNullableField(formData, "medium"),
|
|
dimensions: readOptionalNullableField(formData, "dimensions"),
|
|
year: (() => {
|
|
const raw = readField(formData, "year")
|
|
return raw ? Number(raw) : null
|
|
})(),
|
|
framing: readOptionalNullableField(formData, "framing"),
|
|
availability: readOptionalNullableField(formData, "availability"),
|
|
priceAmountCents: (() => {
|
|
const value = readOptionalNonNegativeInt(formData, "priceAmountCents")
|
|
return value ?? null
|
|
})(),
|
|
priceCurrency: (() => {
|
|
const raw = readField(formData, "priceCurrency").toUpperCase()
|
|
return raw.length === 3 ? raw : null
|
|
})(),
|
|
isPriceVisible: readBooleanField(formData, "isPriceVisible"),
|
|
isPublished: readBooleanField(formData, "isPublished"),
|
|
})
|
|
} catch {
|
|
redirectWithState({ error: "Failed to update artwork refinement fields." })
|
|
}
|
|
|
|
revalidatePath("/portfolio")
|
|
redirectWithState({ notice: "Artwork refinement updated." })
|
|
}
|
|
|
|
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"),
|
|
sortOrder: readNonNegativeInt(formData, "sortOrder"),
|
|
isVisible: readBooleanField(formData, "isVisible"),
|
|
})
|
|
} else if (type === "album") {
|
|
await createAlbum({
|
|
name,
|
|
slug,
|
|
description: readOptionalField(formData, "description"),
|
|
sortOrder: readNonNegativeInt(formData, "sortOrder"),
|
|
isVisible: readBooleanField(formData, "isVisible"),
|
|
})
|
|
} else if (type === "category") {
|
|
await createCategory({
|
|
name,
|
|
slug,
|
|
description: readOptionalField(formData, "description"),
|
|
sortOrder: readNonNegativeInt(formData, "sortOrder"),
|
|
isVisible: readBooleanField(formData, "isVisible"),
|
|
})
|
|
} else {
|
|
await createTag({
|
|
name,
|
|
slug,
|
|
description: readOptionalField(formData, "description"),
|
|
sortOrder: readNonNegativeInt(formData, "sortOrder"),
|
|
isVisible: readBooleanField(formData, "isVisible"),
|
|
})
|
|
}
|
|
} catch {
|
|
redirectWithState({ error: "Failed to create grouping entity." })
|
|
}
|
|
|
|
revalidatePath("/portfolio")
|
|
redirectWithState({ notice: `${type} created.` })
|
|
}
|
|
|
|
async function updateGroupAction(formData: FormData) {
|
|
"use server"
|
|
|
|
await requireWritePermission()
|
|
|
|
try {
|
|
await updateGrouping({
|
|
groupType: readField(formData, "groupType"),
|
|
groupId: readField(formData, "groupId"),
|
|
name: readField(formData, "name"),
|
|
slug: slugify(readField(formData, "slug")),
|
|
description: readOptionalNullableField(formData, "description"),
|
|
sortOrder: readNonNegativeInt(formData, "sortOrder"),
|
|
isVisible: readBooleanField(formData, "isVisible"),
|
|
})
|
|
} catch {
|
|
redirectWithState({ error: "Failed to update grouping entity." })
|
|
}
|
|
|
|
revalidatePath("/portfolio")
|
|
redirectWithState({ notice: "Grouping entity updated." })
|
|
}
|
|
|
|
async function deleteGroupAction(formData: FormData) {
|
|
"use server"
|
|
|
|
await requireWritePermission()
|
|
|
|
try {
|
|
await deleteGrouping({
|
|
groupType: readField(formData, "groupType"),
|
|
groupId: readField(formData, "groupId"),
|
|
})
|
|
} catch {
|
|
redirectWithState({ error: "Failed to delete grouping entity." })
|
|
}
|
|
|
|
revalidatePath("/portfolio")
|
|
redirectWithState({ notice: "Grouping entity deleted." })
|
|
}
|
|
|
|
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." })
|
|
}
|
|
|
|
async function deleteRenditionAction(formData: FormData) {
|
|
"use server"
|
|
|
|
await requireWritePermission()
|
|
|
|
try {
|
|
await deleteArtworkRendition(readField(formData, "renditionId"))
|
|
} catch {
|
|
redirectWithState({ error: "Failed to delete rendition." })
|
|
}
|
|
|
|
revalidatePath("/portfolio")
|
|
redirectWithState({ notice: "Rendition deleted." })
|
|
}
|
|
|
|
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"
|
|
/>
|
|
<div className="grid gap-3 md:grid-cols-3">
|
|
<input
|
|
name="priceAmount"
|
|
type="number"
|
|
step="0.01"
|
|
min={0}
|
|
placeholder="Price amount (e.g. 199.99)"
|
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
/>
|
|
<input
|
|
name="priceCurrency"
|
|
maxLength={3}
|
|
placeholder="Currency (USD)"
|
|
className="rounded border border-neutral-300 px-3 py-2 text-sm uppercase"
|
|
/>
|
|
<label className="flex items-center gap-2 rounded border border-neutral-300 px-3 py-2 text-sm">
|
|
<input type="checkbox" name="isPriceVisible" />
|
|
Price visible
|
|
</label>
|
|
</div>
|
|
<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-5">
|
|
<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"
|
|
/>
|
|
<input
|
|
name="sortOrder"
|
|
type="number"
|
|
min={0}
|
|
defaultValue={0}
|
|
placeholder="Sort order"
|
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
/>
|
|
<label className="flex items-center gap-2 rounded border border-neutral-300 px-3 py-2 text-sm">
|
|
<input type="checkbox" name="isVisible" defaultChecked />
|
|
Visible
|
|
</label>
|
|
</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">Manage Group Entities</h2>
|
|
<div className="mt-4 grid gap-4">
|
|
{(
|
|
[
|
|
{ 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) => (
|
|
<section
|
|
key={`manage-${groupConfig.type}`}
|
|
className="rounded border border-neutral-200 p-4"
|
|
>
|
|
<h3 className="text-sm font-semibold">{groupConfig.label} Entities</h3>
|
|
<div className="mt-3 space-y-3">
|
|
{groupConfig.items.length === 0 ? (
|
|
<p className="text-xs text-neutral-500">No entities created yet.</p>
|
|
) : (
|
|
groupConfig.items.map((group) => (
|
|
<form
|
|
key={`manage-${groupConfig.type}-${group.id}`}
|
|
action={updateGroupAction}
|
|
className="space-y-3 rounded border border-neutral-200 p-3"
|
|
>
|
|
<input type="hidden" name="groupType" value={groupConfig.type} />
|
|
<input type="hidden" name="groupId" value={group.id} />
|
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
<input
|
|
name="name"
|
|
required
|
|
defaultValue={group.name}
|
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
/>
|
|
<input
|
|
name="slug"
|
|
required
|
|
defaultValue={group.slug}
|
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
/>
|
|
<input
|
|
name="sortOrder"
|
|
type="number"
|
|
min={0}
|
|
defaultValue={group.sortOrder}
|
|
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
/>
|
|
<label className="flex items-center gap-2 rounded border border-neutral-300 px-3 py-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
name="isVisible"
|
|
defaultChecked={group.isVisible}
|
|
/>
|
|
Visible
|
|
</label>
|
|
</div>
|
|
<textarea
|
|
name="description"
|
|
rows={2}
|
|
defaultValue={group.description ?? ""}
|
|
placeholder="Description"
|
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
|
/>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button type="submit">Save group</Button>
|
|
<button
|
|
type="submit"
|
|
formAction={deleteGroupAction}
|
|
className="rounded border border-red-300 px-3 py-2 text-sm text-red-700 hover:bg-red-50"
|
|
>
|
|
Delete group
|
|
</button>
|
|
</div>
|
|
</form>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</div>
|
|
</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="retina">retina</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">Refinement</th>
|
|
<th className="py-2 pr-4">Renditions</th>
|
|
<th className="py-2 pr-4">Groups</th>
|
|
<th className="py-2 pr-4">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{artworks.length === 0 ? (
|
|
<tr>
|
|
<td className="py-3 text-neutral-500" colSpan={7}>
|
|
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 text-xs text-neutral-600">
|
|
{artwork.medium ? `medium: ${artwork.medium}` : "medium: -"}
|
|
<br />
|
|
{artwork.dimensions ? `dimensions: ${artwork.dimensions}` : "dimensions: -"}
|
|
<br />
|
|
{artwork.year ? `year: ${artwork.year}` : "year: -"}
|
|
<br />
|
|
{artwork.framing ? `framing: ${artwork.framing}` : "framing: -"}
|
|
<br />
|
|
{artwork.availability
|
|
? `availability: ${artwork.availability}`
|
|
: "availability: -"}
|
|
<br />
|
|
{artwork.priceAmountCents && artwork.priceCurrency
|
|
? `price: ${(artwork.priceAmountCents / 100).toFixed(2)} ${artwork.priceCurrency} (${artwork.isPriceVisible ? "visible" : "hidden"})`
|
|
: "price: -"}
|
|
</td>
|
|
<td className="py-3 pr-4">
|
|
<div className="space-y-1">
|
|
{artwork.renditions.length === 0 ? (
|
|
<span className="text-xs text-neutral-500">0</span>
|
|
) : (
|
|
artwork.renditions.map((rendition) => (
|
|
<form
|
|
key={rendition.id}
|
|
action={deleteRenditionAction}
|
|
className="flex items-center gap-2 text-xs"
|
|
>
|
|
<input type="hidden" name="renditionId" value={rendition.id} />
|
|
<span className="rounded bg-neutral-100 px-2 py-1 font-mono">
|
|
{rendition.slot}
|
|
</span>
|
|
<span className="text-neutral-500">
|
|
{rendition.width ?? "-"}x{rendition.height ?? "-"}
|
|
</span>
|
|
{rendition.isPrimary ? (
|
|
<span className="rounded bg-emerald-100 px-2 py-1 text-emerald-700">
|
|
primary
|
|
</span>
|
|
) : null}
|
|
<button
|
|
type="submit"
|
|
className="rounded border border-red-300 px-2 py-1 text-red-700 hover:bg-red-50"
|
|
>
|
|
delete
|
|
</button>
|
|
</form>
|
|
))
|
|
)}
|
|
</div>
|
|
</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>
|
|
<td className="py-3 pr-4">
|
|
<form action={updateArtworkAction} className="grid min-w-80 gap-2">
|
|
<input type="hidden" name="artworkId" value={artwork.id} />
|
|
<input
|
|
name="medium"
|
|
defaultValue={artwork.medium ?? ""}
|
|
placeholder="Medium"
|
|
className="rounded border border-neutral-300 px-2 py-1 text-xs"
|
|
/>
|
|
<input
|
|
name="dimensions"
|
|
defaultValue={artwork.dimensions ?? ""}
|
|
placeholder="Dimensions"
|
|
className="rounded border border-neutral-300 px-2 py-1 text-xs"
|
|
/>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<input
|
|
name="year"
|
|
type="number"
|
|
defaultValue={artwork.year ?? ""}
|
|
placeholder="Year"
|
|
className="rounded border border-neutral-300 px-2 py-1 text-xs"
|
|
/>
|
|
<input
|
|
name="framing"
|
|
defaultValue={artwork.framing ?? ""}
|
|
placeholder="Framing"
|
|
className="rounded border border-neutral-300 px-2 py-1 text-xs"
|
|
/>
|
|
</div>
|
|
<input
|
|
name="availability"
|
|
defaultValue={artwork.availability ?? ""}
|
|
placeholder="Availability"
|
|
className="rounded border border-neutral-300 px-2 py-1 text-xs"
|
|
/>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<input
|
|
name="priceAmountCents"
|
|
type="number"
|
|
min={0}
|
|
defaultValue={artwork.priceAmountCents ?? ""}
|
|
placeholder="Price cents"
|
|
className="rounded border border-neutral-300 px-2 py-1 text-xs"
|
|
/>
|
|
<input
|
|
name="priceCurrency"
|
|
maxLength={3}
|
|
defaultValue={artwork.priceCurrency ?? ""}
|
|
placeholder="USD"
|
|
className="rounded border border-neutral-300 px-2 py-1 text-xs uppercase"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-xs">
|
|
<label className="inline-flex items-center gap-1">
|
|
<input
|
|
type="checkbox"
|
|
name="isPriceVisible"
|
|
defaultChecked={artwork.isPriceVisible}
|
|
/>
|
|
price visible
|
|
</label>
|
|
<label className="inline-flex items-center gap-1">
|
|
<input
|
|
type="checkbox"
|
|
name="isPublished"
|
|
defaultChecked={artwork.isPublished}
|
|
/>
|
|
published
|
|
</label>
|
|
</div>
|
|
<Button type="submit">Save</Button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</AdminShell>
|
|
)
|
|
}
|