feat(web): add public portfolio rendering and media streaming
This commit is contained in:
178
apps/web/src/app/[locale]/portfolio/page.tsx
Normal file
178
apps/web/src/app/[locale]/portfolio/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user