feat(web): add public portfolio rendering and media streaming

This commit is contained in:
2026-02-12 21:43:53 +01:00
parent 1fddb6d858
commit 958f3ad723
15 changed files with 888 additions and 9 deletions

View File

@@ -0,0 +1,178 @@
import { listPublishedArtworks, listPublishedPortfolioGroups } from "@cms/db"
import Image from "next/image"
import { getTranslations } from "next-intl/server"
import { Link } from "@/i18n/navigation"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
type PortfolioPageProps = {
searchParams: Promise<SearchParamsInput>
}
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
function resolveGroupFilter(searchParams: SearchParamsInput) {
const gallery = readFirstValue(searchParams.gallery)
if (gallery) {
return { groupType: "gallery" as const, groupSlug: gallery }
}
const album = readFirstValue(searchParams.album)
if (album) {
return { groupType: "album" as const, groupSlug: album }
}
const category = readFirstValue(searchParams.category)
if (category) {
return { groupType: "category" as const, groupSlug: category }
}
const tag = readFirstValue(searchParams.tag)
if (tag) {
return { groupType: "tag" as const, groupSlug: tag }
}
return null
}
function findPreviewAsset(
renditions: Array<{
slot: string
mediaAssetId: string
mediaAsset: {
id: string
altText: string | null
title: string
}
}>,
) {
const byPreference =
renditions.find((item) => item.slot === "card") ??
renditions.find((item) => item.slot === "thumbnail") ??
renditions.find((item) => item.slot === "full") ??
renditions[0]
return byPreference ?? null
}
export default async function PortfolioPage({ searchParams }: PortfolioPageProps) {
const [resolvedSearchParams, t] = await Promise.all([searchParams, getTranslations("Portfolio")])
const activeFilter = resolveGroupFilter(resolvedSearchParams)
const [groups, artworks] = await Promise.all([
listPublishedPortfolioGroups(),
listPublishedArtworks(
activeFilter
? {
groupType: activeFilter.groupType,
groupSlug: activeFilter.groupSlug,
}
: undefined,
),
])
return (
<section className="mx-auto w-full max-w-6xl space-y-6 px-6 py-16">
<header className="space-y-2">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-neutral-600">{t("description")}</p>
</header>
<section className="rounded-xl border border-neutral-200 p-4">
<div className="flex flex-wrap items-center gap-2">
<Link
href="/portfolio"
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
>
{t("filters.clear")}
</Link>
{groups.galleries.map((group) => (
<Link
key={`gallery-${group.id}`}
href={{ pathname: "/portfolio", query: { gallery: group.slug } }}
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
>
{t("filters.gallery")}: {group.name}
</Link>
))}
{groups.albums.map((group) => (
<Link
key={`album-${group.id}`}
href={{ pathname: "/portfolio", query: { album: group.slug } }}
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
>
{t("filters.album")}: {group.name}
</Link>
))}
{groups.categories.map((group) => (
<Link
key={`category-${group.id}`}
href={{ pathname: "/portfolio", query: { category: group.slug } }}
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
>
{t("filters.category")}: {group.name}
</Link>
))}
</div>
</section>
{artworks.length === 0 ? (
<section className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600">
{t("empty")}
</section>
) : (
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{artworks.map((artwork) => {
const preview = findPreviewAsset(artwork.renditions)
return (
<article
key={artwork.id}
className="overflow-hidden rounded-xl border border-neutral-200"
>
{preview ? (
<Image
src={`/api/media/file/${preview.mediaAssetId}`}
alt={preview.mediaAsset.altText || artwork.title}
width={1200}
height={800}
className="h-56 w-full object-cover"
/>
) : (
<div className="flex h-56 items-center justify-center bg-neutral-100 text-sm text-neutral-500">
{t("noPreview")}
</div>
)}
<div className="space-y-2 p-4">
<h2 className="text-lg font-medium">{artwork.title}</h2>
<p className="line-clamp-3 text-sm text-neutral-600">
{artwork.description || t("noDescription")}
</p>
<Link
href={`/portfolio/${artwork.slug}`}
className="text-sm underline underline-offset-2"
>
{t("viewArtwork")}
</Link>
</div>
</article>
)
})}
</section>
)}
</section>
)
}