From de103e362a87405a49f940b2f78c4e57bc52cdbb Mon Sep 17 00:00:00 2001 From: Citali Date: Fri, 26 Dec 2025 00:21:30 +0100 Subject: [PATCH] Refactor porfolio page --- .../portfolio/getPortfolioArtworksMeta.ts | 76 ++++++++++ .../portfolio/getPortfolioArtworksPage.ts | 136 ++++++++++-------- src/app/(normal)/artworks/page.tsx | 56 +++++++- .../portfolio/ColorMasonryGallery.tsx | 37 +++-- .../portfolio/PortfolioFiltersBar.tsx | 133 +++++++++++++++++ 5 files changed, 360 insertions(+), 78 deletions(-) create mode 100644 src/actions/portfolio/getPortfolioArtworksMeta.ts create mode 100644 src/components/portfolio/PortfolioFiltersBar.tsx diff --git a/src/actions/portfolio/getPortfolioArtworksMeta.ts b/src/actions/portfolio/getPortfolioArtworksMeta.ts new file mode 100644 index 0000000..882c817 --- /dev/null +++ b/src/actions/portfolio/getPortfolioArtworksMeta.ts @@ -0,0 +1,76 @@ +"use server"; + +import type { PortfolioFilters } from "@/actions/portfolio/getPortfolioArtworksPage"; +import { Prisma } from "@/generated/prisma/browser"; +import { prisma } from "@/lib/prisma"; + +function coerceYear(y: PortfolioFilters["year"]) { + if (y === "all" || y == null) return null; + if (typeof y === "number") return Number.isFinite(y) ? y : null; + return null; +} + +function normQ(q?: string | null) { + const s = (q ?? "").trim(); + return s.length ? s : null; +} + +export async function getPortfolioArtworksMeta(args: { + filters: PortfolioFilters; + onlyPublished?: boolean; +}): Promise<{ + total: number; + years: number[]; + albums: Array<{ id: string; name: string }>; +}> { + const { filters, onlyPublished = true } = args; + + const year = coerceYear(filters.year); + const q = normQ(filters.q); + const albumId = filters.albumId && filters.albumId !== "all" ? filters.albumId : null; + + const baseWhere: Prisma.ArtworkWhereInput = { + ...(onlyPublished ? { published: true } : {}), + ...(year != null ? { year } : {}), + ...(albumId ? { albums: { some: { id: albumId } } } : {}), + variants: { some: { type: "thumbnail" } }, + }; + + const where: Prisma.ArtworkWhereInput = q + ? { + AND: [ + baseWhere, + { + OR: [ + { name: { contains: q, mode: "insensitive" } }, + { slug: { contains: q, mode: "insensitive" } }, + { altText: { contains: q, mode: "insensitive" } }, + { tags: { some: { name: { contains: q, mode: "insensitive" } } } }, + { albums: { some: { name: { contains: q, mode: "insensitive" } } } }, + ], + }, + ], + } + : baseWhere; + + const [total, yearsRaw, albums] = await Promise.all([ + prisma.artwork.count({ where }), + prisma.artwork.findMany({ + where: { ...(onlyPublished ? { published: true } : {}) }, + distinct: ["year"], + select: { year: true }, + orderBy: [{ year: "desc" }], + }), + prisma.album.findMany({ + orderBy: [{ sortIndex: "asc" }, { name: "asc" }], + select: { id: true, name: true }, + }), + ]); + + const years = yearsRaw + .map((r) => r.year) + .filter((y): y is number => typeof y === "number") + .sort((a, b) => b - a); + + return { total, years, albums }; +} diff --git a/src/actions/portfolio/getPortfolioArtworksPage.ts b/src/actions/portfolio/getPortfolioArtworksPage.ts index cb67794..eeb380d 100644 --- a/src/actions/portfolio/getPortfolioArtworksPage.ts +++ b/src/actions/portfolio/getPortfolioArtworksPage.ts @@ -26,24 +26,22 @@ export type PortfolioArtworkItem = { }; export type PortfolioFilters = { - year?: number | null; - galleryId?: string | null; - albumId?: string | null; - tagIds?: string[]; - search?: string; + year?: number | "all" | null; + albumId?: string | "all" | null; + q?: string | null; }; -function coerceYear(year?: number | string | null) { - if (typeof year === "number") return Number.isFinite(year) ? year : null; - if (typeof year === "string") { - const t = year.trim(); - if (!t) return null; - const n = Number(t); - return Number.isFinite(n) ? n : null; - } +function coerceYear(y: PortfolioFilters["year"]) { + if (y === "all" || y == null) return null; + if (typeof y === "number") return Number.isFinite(y) ? y : null; return null; } +function normQ(q?: string | null) { + const s = (q ?? "").trim(); + return s.length ? s : null; +} + type VariantPick = { type: string; width: number; height: number }; function pickVariant(variants: VariantPick[], type: string) { return variants.find((v) => v.type === type) ?? null; @@ -54,44 +52,76 @@ export async function getPortfolioArtworksPage(args: { cursor?: Cursor; filters?: PortfolioFilters; onlyPublished?: boolean; -}): Promise<{ items: PortfolioArtworkItem[]; nextCursor: Cursor }> { +}): Promise<{ + items: PortfolioArtworkItem[]; + nextCursor: Cursor; + total: number; + years: number[]; + albums: Array<{ id: string; name: string }>; +}> { const { take = 60, cursor = null, filters = {}, onlyPublished = true } = args; const year = coerceYear(filters.year ?? null); + const q = normQ(filters.q); + const albumId = filters.albumId && filters.albumId !== "all" ? filters.albumId : null; const baseWhere: Prisma.ArtworkWhereInput = { ...(onlyPublished ? { published: true } : {}), ...(year != null ? { year } : {}), - ...(filters.galleryId ? { galleryId: filters.galleryId } : {}), - ...(filters.albumId ? { albums: { some: { id: filters.albumId } } } : {}), - - ...(filters.tagIds?.length - ? { tags: { some: { id: { in: filters.tagIds } } } } - : {}), - - ...(filters.search?.trim() - ? { - OR: [ - { name: { contains: filters.search.trim(), mode: "insensitive" } }, - { slug: { contains: filters.search.trim(), mode: "insensitive" } }, - { altText: { contains: filters.search.trim(), mode: "insensitive" } }, - ], - } - : {}), + ...(albumId ? { albums: { some: { id: albumId } } } : {}), + variants: { some: { type: "thumbnail" } }, }; - // Require thumbnail variant for grid placement - const baseSelect = { + const where: Prisma.ArtworkWhereInput = q + ? { + AND: [ + baseWhere, + { + OR: [ + { name: { contains: q, mode: "insensitive" } }, + { slug: { contains: q, mode: "insensitive" } }, + { altText: { contains: q, mode: "insensitive" } }, + { tags: { some: { name: { contains: q, mode: "insensitive" } } } }, + { albums: { some: { name: { contains: q, mode: "insensitive" } } } }, + ], + }, + ], + } + : baseWhere; + + const [yearsRaw, albums, total] = await Promise.all([ + prisma.artwork.findMany({ + where: { ...(onlyPublished ? { published: true } : {}) }, + distinct: ["year"], + select: { year: true }, + orderBy: [{ year: "desc" }], + }), + prisma.album.findMany({ + orderBy: [{ sortIndex: "asc" }, { name: "asc" }], + select: { id: true, name: true }, + }), + prisma.artwork.count({ where }), + ]); + + const years = yearsRaw + .map((r) => r.year) + .filter((y): y is number => typeof y === "number") + .sort((a, b) => b - a); + + // Segment logic (sortKey != null first, then null) + const inNullSegment = cursor?.afterSortKey === null; + + const select = { id: true, name: true, - slug: true, altText: true, year: true, sortKey: true, file: { select: { fileKey: true } }, variants: { - where: { type: { in: ["thumbnail"] } }, + where: { type: "thumbnail" }, select: { type: true, width: true, height: true }, + take: 1, }, colors: { where: { type: "Vibrant" }, @@ -118,20 +148,12 @@ export async function getPortfolioArtworksPage(args: { }; }; - // --- Segment handling --- - // Segment A: sortKey != null orderBy sortKey asc, id asc - // Segment B: sortKey == null orderBy id asc - const inNullSegment = cursor?.afterSortKey === null; - - // Query A (non-null segment) unless we're already in null segment let items: PortfolioArtworkItem[] = []; let nextCursor: Cursor = null; if (!inNullSegment) { const whereA: Prisma.ArtworkWhereInput = { - ...baseWhere, - sortKey: { not: null }, - variants: { some: { type: "thumbnail" } }, + AND: [where, { sortKey: { not: null } }], }; if (cursor?.afterSortKey != null) { @@ -146,35 +168,30 @@ export async function getPortfolioArtworksPage(args: { where: whereA, orderBy: [{ sortKey: "asc" }, { id: "asc" }], take: Math.min(take, 200), - select: baseSelect, + select, }); items = rowsA.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null); - // If we fully satisfied take within segment A, return cursor in A if (items.length >= take) { const last = items[items.length - 1]!; nextCursor = { afterSortKey: last.sortKey!, afterId: last.id }; - return { items, nextCursor }; + return { items, nextCursor, total, years, albums }; } - // Otherwise, we will transition into segment B and fill the remainder const remaining = take - items.length; - - const lastAId = items.length > 0 ? items[items.length - 1]!.id : null; + const lastAId = items.length ? items[items.length - 1]!.id : null; const whereB: Prisma.ArtworkWhereInput = { - ...baseWhere, - sortKey: null, - variants: { some: { type: "thumbnail" } }, - ...(lastAId ? { id: { gt: lastAId } } : {}), // IMPORTANT: don't restart from beginning during transition + AND: [where, { sortKey: null }], + ...(lastAId ? { id: { gt: lastAId } } : {}), }; const rowsB = await prisma.artwork.findMany({ where: whereB, orderBy: [{ id: "asc" }], take: Math.min(remaining, 200), - select: baseSelect, + select, }); const more = rowsB.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null); @@ -186,14 +203,11 @@ export async function getPortfolioArtworksPage(args: { ? null : { afterSortKey: last.sortKey ?? null, afterId: last.id }; - return { items, nextCursor }; + return { items, nextCursor, total, years, albums }; } - // Query B (null segment continuation) const whereB: Prisma.ArtworkWhereInput = { - ...baseWhere, - sortKey: null, - variants: { some: { type: "thumbnail" } }, + AND: [where, { sortKey: null }], ...(cursor ? { id: { gt: cursor.afterId } } : {}), }; @@ -201,7 +215,7 @@ export async function getPortfolioArtworksPage(args: { where: whereB, orderBy: [{ id: "asc" }], take: Math.min(take, 200), - select: baseSelect, + select, }); items = rowsB.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null); @@ -210,5 +224,5 @@ export async function getPortfolioArtworksPage(args: { nextCursor = items.length < take || !last ? null : { afterSortKey: null, afterId: last.id }; - return { items, nextCursor }; + return { items, nextCursor, total, years, albums }; } diff --git a/src/app/(normal)/artworks/page.tsx b/src/app/(normal)/artworks/page.tsx index c698304..5765f1c 100644 --- a/src/app/(normal)/artworks/page.tsx +++ b/src/app/(normal)/artworks/page.tsx @@ -1,14 +1,64 @@ +import { PortfolioFilters } from "@/actions/portfolio/getPortfolioArtworksPage"; import ColorMasonryGallery from "@/components/portfolio/ColorMasonryGallery"; +import PortfolioFiltersBar from "@/components/portfolio/PortfolioFiltersBar"; +import { prisma } from "@/lib/prisma"; + +type SearchParams = { + year?: string; + q?: string; +}; + +function parseFilters(sp: SearchParams): PortfolioFilters { + const filters: PortfolioFilters = {}; + + const yearRaw = sp.year?.trim(); + if (yearRaw && yearRaw !== "all") { + const y = Number(yearRaw); + if (Number.isFinite(y) && y > 0) (filters as any).year = y; + } + + const qRaw = sp.q?.trim(); + if (qRaw) (filters as any).q = qRaw; + + return filters; +} + +async function getExistingArtworkYears(): Promise { + const rows = await prisma.artwork.findMany({ + where: { + published: true, + year: { not: null }, + }, + select: { year: true }, + distinct: ["year"], + orderBy: { year: "desc" }, + }); + + return rows + .map((r) => r.year) + .filter((y): y is number => typeof y === "number"); +} + +export default async function PortfolioPage({ + searchParams, +}: { + // Per your requirement: catch searchParams async + searchParams: Promise; +}) { + const sp = await searchParams; + const [years, filters] = await Promise.all([ + getExistingArtworkYears().catch(() => []), // never let this break rendering + Promise.resolve(parseFilters(sp)), + ]); -export default function PortfolioPage() { return (

Portfolio

-

Browse artworks ordered by color.

- + +
); } diff --git a/src/components/portfolio/ColorMasonryGallery.tsx b/src/components/portfolio/ColorMasonryGallery.tsx index ca3cad1..ab45b37 100644 --- a/src/components/portfolio/ColorMasonryGallery.tsx +++ b/src/components/portfolio/ColorMasonryGallery.tsx @@ -20,8 +20,16 @@ type Placement = { dominantHex: string; }; -function computeCols(containerW: number, gap: number, minColW: number, maxCols: number) { - const cols = Math.max(1, Math.min(maxCols, Math.floor((containerW + gap) / (minColW + gap)))); +function computeCols( + containerW: number, + gap: number, + minColW: number, + maxCols: number +) { + const cols = Math.max( + 1, + Math.min(maxCols, Math.floor((containerW + gap) / (minColW + gap))) + ); const colW = Math.floor((containerW - gap * (cols - 1)) / cols); return { cols, colW: Math.max(1, colW) }; } @@ -84,15 +92,13 @@ function useResizeObserverWidth() { } export default function ColorMasonryGallery({ - initialFilters, + filters, }: { - initialFilters?: PortfolioFilters; + filters: PortfolioFilters; }) { const { ref: containerRef, w: containerW } = useResizeObserverWidth(); - const [filters, setFilters] = React.useState(initialFilters ?? {}); const [items, setItems] = React.useState([]); - const [cursor, setCursor] = React.useState(null); const [done, setDone] = React.useState(false); const [loading, setLoading] = React.useState(false); @@ -100,12 +106,14 @@ export default function ColorMasonryGallery({ const doneRef = React.useRef(false); doneRef.current = done; + const cursorRef = React.useRef(null); + React.useEffect(() => { setItems([]); - setCursor(null); setDone(false); doneRef.current = false; inFlight.current = false; + cursorRef.current = null; }, [filters]); const loadMore = React.useCallback(async () => { @@ -115,10 +123,10 @@ export default function ColorMasonryGallery({ try { const data = await getPortfolioArtworksPage({ - take: 80, - cursor, + take: 60, + cursor: cursorRef.current, filters, - onlyPublished: false, + onlyPublished: true, }); // Defensive dedupe: prevents accidental repeats from any future cursor edge case @@ -128,19 +136,20 @@ export default function ColorMasonryGallery({ return prev.concat(next); }); - setCursor(data.nextCursor); + cursorRef.current = data.nextCursor; if (!data.nextCursor) setDone(true); + return data.items.length; } finally { setLoading(false); inFlight.current = false; } - }, [cursor, filters]); + }, [filters]); React.useEffect(() => { void loadMore(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filters]); + }, [loadMore]); const sentinelRef = React.useRef(null); React.useEffect(() => { @@ -197,7 +206,7 @@ export default function ColorMasonryGallery({ "block w-full h-full overflow-hidden rounded-md", "border border-transparent", "transition-colors duration-150", - "hover:border-[color:var(--dom)]", + "hover:border-(--dom)", ].join(" ")} style={{ ["--dom" as any]: p.dominantHex }} aria-label={`Open ${it.name}`} diff --git a/src/components/portfolio/PortfolioFiltersBar.tsx b/src/components/portfolio/PortfolioFiltersBar.tsx new file mode 100644 index 0000000..7b94126 --- /dev/null +++ b/src/components/portfolio/PortfolioFiltersBar.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import * as React from "react"; + +function setParam(params: URLSearchParams, key: string, value?: string | null) { + if (!value) params.delete(key); + else params.set(key, value); +} + +export default function PortfolioFiltersBar({ years = [] }: { years?: number[] }) { + const router = useRouter(); + const pathname = usePathname(); + const sp = useSearchParams(); + + const yearParam = sp.get("year") ?? "all"; + const qParam = sp.get("q") ?? ""; + + // Local input state (typing does NOT change URL) + const [q, setQ] = React.useState(qParam); + + // Sync input when navigating back/forward (URL -> input) + React.useEffect(() => { + setQ(qParam); + }, [qParam]); + + const pushParams = React.useCallback( + (mutate: (next: URLSearchParams) => void) => { + const next = new URLSearchParams(sp.toString()); + mutate(next); + + const nextQs = next.toString(); + const currQs = sp.toString(); + if (nextQs === currQs) return; // guard against redundant replaces + + router.replace(nextQs ? `${pathname}?${nextQs}` : pathname, { scroll: false }); + }, + [pathname, router, sp] + ); + + const setYear = (year: "all" | number) => { + pushParams((next) => { + setParam(next, "year", year === "all" ? null : String(year)); + }); + }; + + const submitSearch = (value: string) => { + const trimmed = value.trim(); + pushParams((next) => { + setParam(next, "q", trimmed.length ? trimmed : null); + }); + }; + + const clear = () => { + setQ(""); + pushParams((next) => { + next.delete("year"); + next.delete("q"); + }); + }; + + return ( +
+
+
Year
+
+ + + {years.map((y) => { + const active = yearParam === String(y); + return ( + + ); + })} +
+
+ +
{ + e.preventDefault(); + submitSearch(q); + }} + > +
Search (by name or tags)
+ +
+ setQ(e.target.value)} + placeholder="e.g. portrait, berlin, oil…" + inputMode="search" + /> + + + + +
+
+
+ ); +}