feat(portfolio): add rendition management controls
This commit is contained in:
3
TODO.md
3
TODO.md
@@ -143,7 +143,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [x] [P1] Media enrichment metadata (alt text, copyright, author, source, tags, licensing, usage context)
|
||||
- [x] [P1] Portfolio grouping primitives (galleries, albums, categories, tags) with ordering/visibility controls
|
||||
- [x] [P1] Artwork refinement fields (medium, dimensions, year, framing, availability, price visibility)
|
||||
- [ ] [P1] Artwork rendition management (thumbnail, card, full, retina/custom sizes)
|
||||
- [x] [P1] Artwork rendition management (thumbnail, card, full, retina/custom sizes)
|
||||
- [ ] [P1] Type-specific processing presets (artwork/banner/promo/video/gif) with validation rules
|
||||
- [ ] [P1] Users management (invite, roles, status)
|
||||
- [ ] [P1] Disable/ban user function and enforcement in auth/session checks
|
||||
@@ -363,6 +363,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [2026-02-12] Public portfolio baseline added with `/{locale}/portfolio` and `/{locale}/portfolio/{slug}`, including published-artwork filters (gallery/album/category/tag), rendition image streaming via web `/api/media/file/:id`, and media-aware artwork detail rendering.
|
||||
- [2026-02-12] Portfolio grouping controls completed in admin `/portfolio`: galleries/albums/categories/tags now support visibility and sort-order management (create/update/delete), and public tag filters now respect visibility.
|
||||
- [2026-02-12] Artwork refinement baseline completed: admin `/portfolio` now captures/edits medium, dimensions, year, framing, availability, publish state, and optional price visibility (`priceAmountCents` + `priceCurrency`), with public artwork detail rendering visible prices only.
|
||||
- [2026-02-12] Artwork rendition management completed: admin `/portfolio` supports `thumbnail/card/full/retina/custom` slot assignment with dimensions and primary flag, plus per-artwork rendition listing and delete controls.
|
||||
- [2026-02-12] Public UX pass: commission request flow now reports explicit invalid budget range errors, and header navigation now falls back to localized defaults (`home`, `portfolio`, `news`, `commissions`) when no CMS menu exists; seed data now creates those default menu entries.
|
||||
- [2026-02-12] Added `e2e/public-rendering.pw.ts` web coverage for fallback navigation visibility, portfolio routes, and commission submission validation (invalid budget range + successful submission path).
|
||||
- [2026-02-12] Testing execution is temporarily paused for delivery velocity: root test scripts are stubbed and CI test steps are disabled; all testing backlog is consolidated under `MVP 3: Testing and Quality`.
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createCategory,
|
||||
createGallery,
|
||||
createTag,
|
||||
deleteArtworkRendition,
|
||||
deleteGrouping,
|
||||
linkArtworkToGrouping,
|
||||
listArtworks,
|
||||
@@ -316,6 +317,21 @@ async function attachRenditionAction(formData: FormData) {
|
||||
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,
|
||||
}: {
|
||||
@@ -641,6 +657,7 @@ export default async function PortfolioPage({
|
||||
<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
|
||||
@@ -719,7 +736,40 @@ export default async function PortfolioPage({
|
||||
? `price: ${(artwork.priceAmountCents / 100).toFixed(2)} ${artwork.priceCurrency} (${artwork.isPriceVisible ? "visible" : "hidden"})`
|
||||
: "price: -"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">{artwork.renditions.length}</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}
|
||||
|
||||
@@ -9,7 +9,7 @@ export const mediaAssetTypeSchema = z.enum([
|
||||
"generic",
|
||||
])
|
||||
|
||||
export const artworkRenditionSlotSchema = z.enum(["thumbnail", "card", "full", "custom"])
|
||||
export const artworkRenditionSlotSchema = z.enum(["thumbnail", "card", "full", "retina", "custom"])
|
||||
|
||||
export const createMediaAssetInputSchema = z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
|
||||
@@ -24,6 +24,7 @@ export {
|
||||
createGallery,
|
||||
createMediaAsset,
|
||||
createTag,
|
||||
deleteArtworkRendition,
|
||||
deleteGrouping,
|
||||
deleteMediaAsset,
|
||||
getMediaAssetById,
|
||||
|
||||
@@ -33,10 +33,14 @@ export async function listArtworks(limit = 24) {
|
||||
take: limit,
|
||||
include: {
|
||||
renditions: {
|
||||
orderBy: [{ isPrimary: "desc" }, { updatedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
slot: true,
|
||||
mediaAssetId: true,
|
||||
width: true,
|
||||
height: true,
|
||||
isPrimary: true,
|
||||
},
|
||||
},
|
||||
galleryLinks: {
|
||||
@@ -340,6 +344,12 @@ export async function attachArtworkRendition(input: unknown) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteArtworkRendition(id: string) {
|
||||
return db.artworkRendition.delete({
|
||||
where: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getMediaFoundationSummary() {
|
||||
const [mediaAssets, artworks, galleries, albums, categories, tags] = await Promise.all([
|
||||
db.mediaAsset.count(),
|
||||
@@ -473,6 +483,7 @@ export async function listPublishedArtworks(input: ListPublishedArtworksInput =
|
||||
isPublished: true,
|
||||
},
|
||||
},
|
||||
orderBy: [{ isPrimary: "desc" }, { updatedAt: "desc" }],
|
||||
include: {
|
||||
mediaAsset: {
|
||||
select: {
|
||||
@@ -547,6 +558,7 @@ export async function getPublishedArtworkBySlug(slug: string) {
|
||||
isPublished: true,
|
||||
},
|
||||
},
|
||||
orderBy: [{ isPrimary: "desc" }, { updatedAt: "desc" }],
|
||||
include: {
|
||||
mediaAsset: {
|
||||
select: {
|
||||
|
||||
Reference in New Issue
Block a user