292 lines
9.1 KiB
TypeScript
292 lines
9.1 KiB
TypeScript
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 resolveSort(searchParams: SearchParamsInput): "latest" | "title_asc" | "title_desc" {
|
|
const sort = readFirstValue(searchParams.sort)
|
|
|
|
if (sort === "title_asc" || sort === "title_desc") {
|
|
return sort
|
|
}
|
|
|
|
return "latest"
|
|
}
|
|
|
|
function buildPortfolioQuery(
|
|
filter: ReturnType<typeof resolveGroupFilter>,
|
|
sort: ReturnType<typeof resolveSort>,
|
|
) {
|
|
const query: Record<string, string> = {}
|
|
|
|
if (filter) {
|
|
query[filter.groupType] = filter.groupSlug
|
|
}
|
|
|
|
if (sort !== "latest") {
|
|
query.sort = sort
|
|
}
|
|
|
|
return query
|
|
}
|
|
|
|
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 activeSort = resolveSort(resolvedSearchParams)
|
|
|
|
const [groups, artworks] = await Promise.all([
|
|
listPublishedPortfolioGroups(),
|
|
listPublishedArtworks(
|
|
activeFilter
|
|
? {
|
|
groupType: activeFilter.groupType,
|
|
groupSlug: activeFilter.groupSlug,
|
|
sort: activeSort,
|
|
}
|
|
: {
|
|
sort: activeSort,
|
|
},
|
|
),
|
|
])
|
|
|
|
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={{
|
|
pathname: "/portfolio",
|
|
query: activeSort === "latest" ? undefined : { sort: activeSort },
|
|
}}
|
|
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: {
|
|
...buildPortfolioQuery(activeFilter, activeSort),
|
|
gallery: group.slug,
|
|
album: undefined,
|
|
category: undefined,
|
|
tag: undefined,
|
|
},
|
|
}}
|
|
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: {
|
|
...buildPortfolioQuery(activeFilter, activeSort),
|
|
gallery: undefined,
|
|
album: group.slug,
|
|
category: undefined,
|
|
tag: undefined,
|
|
},
|
|
}}
|
|
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: {
|
|
...buildPortfolioQuery(activeFilter, activeSort),
|
|
gallery: undefined,
|
|
album: undefined,
|
|
category: group.slug,
|
|
tag: undefined,
|
|
},
|
|
}}
|
|
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>
|
|
))}
|
|
|
|
{groups.tags.map((group) => (
|
|
<Link
|
|
key={`tag-${group.id}`}
|
|
href={{
|
|
pathname: "/portfolio",
|
|
query: {
|
|
...buildPortfolioQuery(activeFilter, activeSort),
|
|
gallery: undefined,
|
|
album: undefined,
|
|
category: undefined,
|
|
tag: 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.tag")}: {group.name}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
<span className="text-xs uppercase tracking-wide text-neutral-500">
|
|
{t("sort.label")}:
|
|
</span>
|
|
<Link
|
|
href={{
|
|
pathname: "/portfolio",
|
|
query: buildPortfolioQuery(activeFilter, "latest"),
|
|
}}
|
|
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
|
|
>
|
|
{t("sort.latest")}
|
|
</Link>
|
|
<Link
|
|
href={{
|
|
pathname: "/portfolio",
|
|
query: buildPortfolioQuery(activeFilter, "title_asc"),
|
|
}}
|
|
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
|
|
>
|
|
{t("sort.titleAsc")}
|
|
</Link>
|
|
<Link
|
|
href={{
|
|
pathname: "/portfolio",
|
|
query: buildPortfolioQuery(activeFilter, "title_desc"),
|
|
}}
|
|
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
|
|
>
|
|
{t("sort.titleDesc")}
|
|
</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>
|
|
)
|
|
}
|