Refactor porfolio page
This commit is contained in:
@ -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}`}
|
||||
|
||||
133
src/components/portfolio/PortfolioFiltersBar.tsx
Normal file
133
src/components/portfolio/PortfolioFiltersBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user