Refactor porfolio page
This commit is contained in:
76
src/actions/portfolio/getPortfolioArtworksMeta.ts
Normal file
76
src/actions/portfolio/getPortfolioArtworksMeta.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -26,24 +26,22 @@ export type PortfolioArtworkItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type PortfolioFilters = {
|
export type PortfolioFilters = {
|
||||||
year?: number | null;
|
year?: number | "all" | null;
|
||||||
galleryId?: string | null;
|
albumId?: string | "all" | null;
|
||||||
albumId?: string | null;
|
q?: string | null;
|
||||||
tagIds?: string[];
|
|
||||||
search?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function coerceYear(year?: number | string | null) {
|
function coerceYear(y: PortfolioFilters["year"]) {
|
||||||
if (typeof year === "number") return Number.isFinite(year) ? year : null;
|
if (y === "all" || y == null) return null;
|
||||||
if (typeof year === "string") {
|
if (typeof y === "number") return Number.isFinite(y) ? y : null;
|
||||||
const t = year.trim();
|
|
||||||
if (!t) return null;
|
|
||||||
const n = Number(t);
|
|
||||||
return Number.isFinite(n) ? n : null;
|
|
||||||
}
|
|
||||||
return 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 };
|
type VariantPick = { type: string; width: number; height: number };
|
||||||
function pickVariant(variants: VariantPick[], type: string) {
|
function pickVariant(variants: VariantPick[], type: string) {
|
||||||
return variants.find((v) => v.type === type) ?? null;
|
return variants.find((v) => v.type === type) ?? null;
|
||||||
@ -54,44 +52,76 @@ export async function getPortfolioArtworksPage(args: {
|
|||||||
cursor?: Cursor;
|
cursor?: Cursor;
|
||||||
filters?: PortfolioFilters;
|
filters?: PortfolioFilters;
|
||||||
onlyPublished?: boolean;
|
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 { take = 60, cursor = null, filters = {}, onlyPublished = true } = args;
|
||||||
|
|
||||||
const year = coerceYear(filters.year ?? null);
|
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 = {
|
const baseWhere: Prisma.ArtworkWhereInput = {
|
||||||
...(onlyPublished ? { published: true } : {}),
|
...(onlyPublished ? { published: true } : {}),
|
||||||
...(year != null ? { year } : {}),
|
...(year != null ? { year } : {}),
|
||||||
...(filters.galleryId ? { galleryId: filters.galleryId } : {}),
|
...(albumId ? { albums: { some: { id: albumId } } } : {}),
|
||||||
...(filters.albumId ? { albums: { some: { id: filters.albumId } } } : {}),
|
variants: { some: { type: "thumbnail" } },
|
||||||
|
|
||||||
...(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" } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Require thumbnail variant for grid placement
|
const where: Prisma.ArtworkWhereInput = q
|
||||||
const baseSelect = {
|
? {
|
||||||
|
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,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
slug: true,
|
|
||||||
altText: true,
|
altText: true,
|
||||||
year: true,
|
year: true,
|
||||||
sortKey: true,
|
sortKey: true,
|
||||||
file: { select: { fileKey: true } },
|
file: { select: { fileKey: true } },
|
||||||
variants: {
|
variants: {
|
||||||
where: { type: { in: ["thumbnail"] } },
|
where: { type: "thumbnail" },
|
||||||
select: { type: true, width: true, height: true },
|
select: { type: true, width: true, height: true },
|
||||||
|
take: 1,
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
where: { type: "Vibrant" },
|
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 items: PortfolioArtworkItem[] = [];
|
||||||
let nextCursor: Cursor = null;
|
let nextCursor: Cursor = null;
|
||||||
|
|
||||||
if (!inNullSegment) {
|
if (!inNullSegment) {
|
||||||
const whereA: Prisma.ArtworkWhereInput = {
|
const whereA: Prisma.ArtworkWhereInput = {
|
||||||
...baseWhere,
|
AND: [where, { sortKey: { not: null } }],
|
||||||
sortKey: { not: null },
|
|
||||||
variants: { some: { type: "thumbnail" } },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cursor?.afterSortKey != null) {
|
if (cursor?.afterSortKey != null) {
|
||||||
@ -146,35 +168,30 @@ export async function getPortfolioArtworksPage(args: {
|
|||||||
where: whereA,
|
where: whereA,
|
||||||
orderBy: [{ sortKey: "asc" }, { id: "asc" }],
|
orderBy: [{ sortKey: "asc" }, { id: "asc" }],
|
||||||
take: Math.min(take, 200),
|
take: Math.min(take, 200),
|
||||||
select: baseSelect,
|
select,
|
||||||
});
|
});
|
||||||
|
|
||||||
items = rowsA.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null);
|
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) {
|
if (items.length >= take) {
|
||||||
const last = items[items.length - 1]!;
|
const last = items[items.length - 1]!;
|
||||||
nextCursor = { afterSortKey: last.sortKey!, afterId: last.id };
|
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 remaining = take - items.length;
|
||||||
|
const lastAId = items.length ? items[items.length - 1]!.id : null;
|
||||||
const lastAId = items.length > 0 ? items[items.length - 1]!.id : null;
|
|
||||||
|
|
||||||
const whereB: Prisma.ArtworkWhereInput = {
|
const whereB: Prisma.ArtworkWhereInput = {
|
||||||
...baseWhere,
|
AND: [where, { sortKey: null }],
|
||||||
sortKey: null,
|
...(lastAId ? { id: { gt: lastAId } } : {}),
|
||||||
variants: { some: { type: "thumbnail" } },
|
|
||||||
...(lastAId ? { id: { gt: lastAId } } : {}), // IMPORTANT: don't restart from beginning during transition
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const rowsB = await prisma.artwork.findMany({
|
const rowsB = await prisma.artwork.findMany({
|
||||||
where: whereB,
|
where: whereB,
|
||||||
orderBy: [{ id: "asc" }],
|
orderBy: [{ id: "asc" }],
|
||||||
take: Math.min(remaining, 200),
|
take: Math.min(remaining, 200),
|
||||||
select: baseSelect,
|
select,
|
||||||
});
|
});
|
||||||
|
|
||||||
const more = rowsB.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null);
|
const more = rowsB.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null);
|
||||||
@ -186,14 +203,11 @@ export async function getPortfolioArtworksPage(args: {
|
|||||||
? null
|
? null
|
||||||
: { afterSortKey: last.sortKey ?? null, afterId: last.id };
|
: { 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 = {
|
const whereB: Prisma.ArtworkWhereInput = {
|
||||||
...baseWhere,
|
AND: [where, { sortKey: null }],
|
||||||
sortKey: null,
|
|
||||||
variants: { some: { type: "thumbnail" } },
|
|
||||||
...(cursor ? { id: { gt: cursor.afterId } } : {}),
|
...(cursor ? { id: { gt: cursor.afterId } } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -201,7 +215,7 @@ export async function getPortfolioArtworksPage(args: {
|
|||||||
where: whereB,
|
where: whereB,
|
||||||
orderBy: [{ id: "asc" }],
|
orderBy: [{ id: "asc" }],
|
||||||
take: Math.min(take, 200),
|
take: Math.min(take, 200),
|
||||||
select: baseSelect,
|
select,
|
||||||
});
|
});
|
||||||
|
|
||||||
items = rowsB.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null);
|
items = rowsB.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null);
|
||||||
@ -210,5 +224,5 @@ export async function getPortfolioArtworksPage(args: {
|
|||||||
nextCursor =
|
nextCursor =
|
||||||
items.length < take || !last ? null : { afterSortKey: null, afterId: last.id };
|
items.length < take || !last ? null : { afterSortKey: null, afterId: last.id };
|
||||||
|
|
||||||
return { items, nextCursor };
|
return { items, nextCursor, total, years, albums };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,64 @@
|
|||||||
|
import { PortfolioFilters } from "@/actions/portfolio/getPortfolioArtworksPage";
|
||||||
import ColorMasonryGallery from "@/components/portfolio/ColorMasonryGallery";
|
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 (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl px-4 py-8">
|
<div className="mx-auto w-full max-w-6xl px-4 py-8">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-semibold">Portfolio</h1>
|
<h1 className="text-2xl font-semibold">Portfolio</h1>
|
||||||
<p className="text-sm text-muted-foreground">Browse artworks ordered by color.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ColorMasonryGallery />
|
<PortfolioFiltersBar years={years} />
|
||||||
|
<ColorMasonryGallery filters={filters} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,8 +20,16 @@ type Placement = {
|
|||||||
dominantHex: string;
|
dominantHex: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function computeCols(containerW: number, gap: number, minColW: number, maxCols: number) {
|
function computeCols(
|
||||||
const cols = Math.max(1, Math.min(maxCols, Math.floor((containerW + gap) / (minColW + gap))));
|
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);
|
const colW = Math.floor((containerW - gap * (cols - 1)) / cols);
|
||||||
return { cols, colW: Math.max(1, colW) };
|
return { cols, colW: Math.max(1, colW) };
|
||||||
}
|
}
|
||||||
@ -84,15 +92,13 @@ function useResizeObserverWidth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ColorMasonryGallery({
|
export default function ColorMasonryGallery({
|
||||||
initialFilters,
|
filters,
|
||||||
}: {
|
}: {
|
||||||
initialFilters?: PortfolioFilters;
|
filters: PortfolioFilters;
|
||||||
}) {
|
}) {
|
||||||
const { ref: containerRef, w: containerW } = useResizeObserverWidth();
|
const { ref: containerRef, w: containerW } = useResizeObserverWidth();
|
||||||
|
|
||||||
const [filters, setFilters] = React.useState<PortfolioFilters>(initialFilters ?? {});
|
|
||||||
const [items, setItems] = React.useState<PortfolioArtworkItem[]>([]);
|
const [items, setItems] = React.useState<PortfolioArtworkItem[]>([]);
|
||||||
const [cursor, setCursor] = React.useState<Cursor>(null);
|
|
||||||
const [done, setDone] = React.useState(false);
|
const [done, setDone] = React.useState(false);
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
|
||||||
@ -100,12 +106,14 @@ export default function ColorMasonryGallery({
|
|||||||
const doneRef = React.useRef(false);
|
const doneRef = React.useRef(false);
|
||||||
doneRef.current = done;
|
doneRef.current = done;
|
||||||
|
|
||||||
|
const cursorRef = React.useRef<Cursor>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
setCursor(null);
|
|
||||||
setDone(false);
|
setDone(false);
|
||||||
doneRef.current = false;
|
doneRef.current = false;
|
||||||
inFlight.current = false;
|
inFlight.current = false;
|
||||||
|
cursorRef.current = null;
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
const loadMore = React.useCallback(async () => {
|
const loadMore = React.useCallback(async () => {
|
||||||
@ -115,10 +123,10 @@ export default function ColorMasonryGallery({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getPortfolioArtworksPage({
|
const data = await getPortfolioArtworksPage({
|
||||||
take: 80,
|
take: 60,
|
||||||
cursor,
|
cursor: cursorRef.current,
|
||||||
filters,
|
filters,
|
||||||
onlyPublished: false,
|
onlyPublished: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Defensive dedupe: prevents accidental repeats from any future cursor edge case
|
// Defensive dedupe: prevents accidental repeats from any future cursor edge case
|
||||||
@ -128,19 +136,20 @@ export default function ColorMasonryGallery({
|
|||||||
return prev.concat(next);
|
return prev.concat(next);
|
||||||
});
|
});
|
||||||
|
|
||||||
setCursor(data.nextCursor);
|
cursorRef.current = data.nextCursor;
|
||||||
if (!data.nextCursor) setDone(true);
|
if (!data.nextCursor) setDone(true);
|
||||||
|
|
||||||
return data.items.length;
|
return data.items.length;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
inFlight.current = false;
|
inFlight.current = false;
|
||||||
}
|
}
|
||||||
}, [cursor, filters]);
|
}, [filters]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void loadMore();
|
void loadMore();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filters]);
|
}, [loadMore]);
|
||||||
|
|
||||||
const sentinelRef = React.useRef<HTMLDivElement>(null);
|
const sentinelRef = React.useRef<HTMLDivElement>(null);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -197,7 +206,7 @@ export default function ColorMasonryGallery({
|
|||||||
"block w-full h-full overflow-hidden rounded-md",
|
"block w-full h-full overflow-hidden rounded-md",
|
||||||
"border border-transparent",
|
"border border-transparent",
|
||||||
"transition-colors duration-150",
|
"transition-colors duration-150",
|
||||||
"hover:border-[color:var(--dom)]",
|
"hover:border-(--dom)",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
style={{ ["--dom" as any]: p.dominantHex }}
|
style={{ ["--dom" as any]: p.dominantHex }}
|
||||||
aria-label={`Open ${it.name}`}
|
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