Refactor porfolio page

This commit is contained in:
2025-12-26 00:21:30 +01:00
parent 3bd555a17a
commit de103e362a
5 changed files with 360 additions and 78 deletions

View File

@ -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 };
}

View File

@ -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 };
}

View File

@ -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<number[]> {
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<SearchParams>;
}) {
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 (
<div className="mx-auto w-full max-w-6xl px-4 py-8">
<div className="mb-6">
<h1 className="text-2xl font-semibold">Portfolio</h1>
<p className="text-sm text-muted-foreground">Browse artworks ordered by color.</p>
</div>
<ColorMasonryGallery />
<PortfolioFiltersBar years={years} />
<ColorMasonryGallery filters={filters} />
</div>
);
}

View File

@ -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<PortfolioFilters>(initialFilters ?? {});
const [items, setItems] = React.useState<PortfolioArtworkItem[]>([]);
const [cursor, setCursor] = React.useState<Cursor>(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<Cursor>(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<HTMLDivElement>(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}`}

View File

@ -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 (
<div className="mb-6 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<div className="text-sm text-muted-foreground">Year</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setYear("all")}
className={[
"h-9 rounded-md border px-3 text-sm",
yearParam === "all" ? "bg-accent" : "hover:bg-accent/60",
].join(" ")}
>
All
</button>
{years.map((y) => {
const active = yearParam === String(y);
return (
<button
key={y}
type="button"
onClick={() => setYear(y)}
className={[
"h-9 rounded-md border px-3 text-sm",
active ? "bg-accent" : "hover:bg-accent/60",
].join(" ")}
>
{y}
</button>
);
})}
</div>
</div>
<form
className="flex flex-col gap-1 sm:max-w-xl"
onSubmit={(e) => {
e.preventDefault();
submitSearch(q);
}}
>
<div className="text-sm text-muted-foreground">Search (by name or tags)</div>
<div className="flex gap-2">
<input
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="e.g. portrait, berlin, oil…"
inputMode="search"
/>
<button
type="submit"
className="h-10 rounded-md border px-3 text-sm hover:bg-accent"
>
Search
</button>
<button
type="button"
className="h-10 rounded-md border px-3 text-sm hover:bg-accent"
onClick={clear}
>
Clear
</button>
</div>
</form>
</div>
);
}