From 96efd4c942d021213c70bf5b3b0d895ec1b0f833 Mon Sep 17 00:00:00 2001 From: Citali Date: Sat, 31 Jan 2026 01:18:46 +0100 Subject: [PATCH] Unify portfolio and animal studies galleries --- .../animalStudies/getAnimalStudiesPage.ts | 94 ++++++ .../portfolio/getPortfolioArtworksPage.ts | 47 ++- .../artworks/animalstudies/index/page.tsx | 2 +- .../(normal)/artworks/animalstudies/page.tsx | 44 +-- src/app/(normal)/artworks/page.tsx | 28 +- src/app/globals.css | 2 +- .../animalStudies/AnimalStudiesGallery.tsx | 76 +++++ .../artworks/ArtworkThumbGallery.tsx | 117 ------- .../artworks/ArtworkTimelapseViewer.tsx | 4 +- src/components/artworks/ContextBackButton.tsx | 5 +- src/components/artworks/TagFilterDialog.tsx | 34 +- src/components/commissions/FileDropzone.tsx | 7 +- src/components/gallery/JustifiedGallery.tsx | 291 ++++++++++++++++++ .../portfolio/ColorMasonryGallery.tsx | 224 -------------- .../portfolio/PortfolioFiltersBar.tsx | 220 +++++++------ src/components/portfolio/PortfolioGallery.tsx | 131 ++++++++ src/schemas/commissionOrder.ts | 2 +- 17 files changed, 800 insertions(+), 528 deletions(-) create mode 100644 src/actions/animalStudies/getAnimalStudiesPage.ts create mode 100644 src/components/animalStudies/AnimalStudiesGallery.tsx delete mode 100644 src/components/artworks/ArtworkThumbGallery.tsx create mode 100644 src/components/gallery/JustifiedGallery.tsx delete mode 100644 src/components/portfolio/ColorMasonryGallery.tsx create mode 100644 src/components/portfolio/PortfolioGallery.tsx diff --git a/src/actions/animalStudies/getAnimalStudiesPage.ts b/src/actions/animalStudies/getAnimalStudiesPage.ts new file mode 100644 index 0000000..c3a49ed --- /dev/null +++ b/src/actions/animalStudies/getAnimalStudiesPage.ts @@ -0,0 +1,94 @@ +"use server"; + +import type { JustifiedGalleryItem } from "@/components/gallery/JustifiedGallery"; +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/prisma"; +import { z } from "zod"; + +export type AnimalStudiesCursor = { sortKey: number; id: string } | null; + +export type AnimalStudiesPage = { + items: JustifiedGalleryItem[]; + nextCursor: AnimalStudiesCursor; +}; + +const inputSchema = z.object({ + take: z.number().int().min(1).max(200).default(60), + cursor: z + .object({ + sortKey: z.number().int(), + id: z.string().min(1), + }) + .nullable() + .optional(), + tagSlugs: z.array(z.string()).optional(), +}); + +export async function getAnimalStudiesPage(input: unknown): Promise { + const { take, cursor, tagSlugs } = inputSchema.parse(input); + + const where: Prisma.ArtworkWhereInput = { + published: true, + // enforce deterministic ordering / pagination + sortKey: { not: null }, + categories: { some: { name: "Animal Studies" } }, + }; + + if (tagSlugs?.length) { + where.tags = { some: { slug: { in: tagSlugs } } }; + } + + if (cursor) { + where.OR = [ + { sortKey: { gt: cursor.sortKey } }, + { sortKey: cursor.sortKey, id: { gt: cursor.id } }, + ]; + } + + const rows = await prisma.artwork.findMany({ + where, + select: { + id: true, + name: true, + altText: true, + sortKey: true, + file: { select: { fileKey: true } }, + variants: { + where: { type: "resized" }, + select: { width: true, height: true }, + take: 1, + }, + metadata: { select: { width: true, height: true } }, + colors: { + select: { color: { select: { hex: true } } }, + take: 1, + }, + }, + orderBy: [{ sortKey: "asc" }, { id: "asc" }], + take: take + 1, + }); + + const slice = rows.slice(0, take); + const next = rows.length > take ? rows[take] : null; + + const items: JustifiedGalleryItem[] = slice.map((r) => { + const v = r.variants[0]; + const w = v?.width ?? r.metadata?.width ?? 4; + const h = v?.height ?? r.metadata?.height ?? 3; + + return { + id: r.id, + name: r.name, + altText: r.altText, + fileKey: r.file.fileKey, + width: w, + height: h, + dominantHex: r.colors?.[0]?.color?.hex ?? null, + }; + }); + + const nextCursor: AnimalStudiesCursor = + next && next.sortKey != null ? { sortKey: next.sortKey, id: next.id } : null; + + return { items, nextCursor }; +} diff --git a/src/actions/portfolio/getPortfolioArtworksPage.ts b/src/actions/portfolio/getPortfolioArtworksPage.ts index eeb380d..b690521 100644 --- a/src/actions/portfolio/getPortfolioArtworksPage.ts +++ b/src/actions/portfolio/getPortfolioArtworksPage.ts @@ -1,6 +1,6 @@ "use server"; -import { Prisma } from "@/generated/prisma/browser"; +import type { Prisma } from "@/generated/prisma/browser"; import { prisma } from "@/lib/prisma"; export type Cursor = { @@ -11,7 +11,6 @@ export type Cursor = { export type PortfolioArtworkItem = { id: string; name: string; - slug: string; altText: string | null; sortKey: number | null; @@ -63,7 +62,8 @@ export async function getPortfolioArtworksPage(args: { const year = coerceYear(filters.year ?? null); const q = normQ(filters.q); - const albumId = filters.albumId && filters.albumId !== "all" ? filters.albumId : null; + const albumId = + filters.albumId && filters.albumId !== "all" ? filters.albumId : null; const baseWhere: Prisma.ArtworkWhereInput = { ...(onlyPublished ? { published: true } : {}), @@ -79,10 +79,9 @@ export async function getPortfolioArtworksPage(args: { { 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" } } } }, + { + tags: { some: { name: { contains: q, mode: "insensitive" } } }, + }, ], }, ], @@ -130,14 +129,15 @@ export async function getPortfolioArtworksPage(args: { }, } satisfies Prisma.ArtworkSelect; - const mapRow = (r: any): PortfolioArtworkItem | null => { + type ArtworkRow = Prisma.ArtworkGetPayload<{ select: typeof select }>; + + const mapRow = (r: ArtworkRow): PortfolioArtworkItem | null => { const thumb = pickVariant(r.variants, "thumbnail"); if (!thumb?.width || !thumb?.height) return null; return { id: r.id, name: r.name, - slug: r.slug, altText: r.altText ?? null, sortKey: r.sortKey ?? null, year: r.year ?? null, @@ -171,20 +171,27 @@ export async function getPortfolioArtworksPage(args: { select, }); - items = rowsA.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null); + items = rowsA + .map(mapRow) + .filter((x): x is PortfolioArtworkItem => x !== null); if (items.length >= take) { - const last = items[items.length - 1]!; - nextCursor = { afterSortKey: last.sortKey!, afterId: last.id }; + const last = items.at(-1); + if (!last) { + return { items, nextCursor: null, total, years, albums }; + } + // last.sortKey can be null only in null-segment, which we are not in here. + if (last.sortKey == null) { + return { items, nextCursor: null, total, years, albums }; + } + nextCursor = { afterSortKey: last.sortKey, afterId: last.id }; return { items, nextCursor, total, years, albums }; } const remaining = take - items.length; - const lastAId = items.length ? items[items.length - 1]!.id : null; const whereB: Prisma.ArtworkWhereInput = { AND: [where, { sortKey: null }], - ...(lastAId ? { id: { gt: lastAId } } : {}), }; const rowsB = await prisma.artwork.findMany({ @@ -194,7 +201,9 @@ export async function getPortfolioArtworksPage(args: { 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); items = items.concat(more); const last = items[items.length - 1]; @@ -218,11 +227,15 @@ export async function getPortfolioArtworksPage(args: { select, }); - items = rowsB.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null); + items = rowsB + .map(mapRow) + .filter((x): x is PortfolioArtworkItem => x !== null); const last = items[items.length - 1]; nextCursor = - items.length < take || !last ? null : { afterSortKey: null, afterId: last.id }; + items.length < take || !last + ? null + : { afterSortKey: null, afterId: last.id }; return { items, nextCursor, total, years, albums }; } diff --git a/src/app/(normal)/artworks/animalstudies/index/page.tsx b/src/app/(normal)/artworks/animalstudies/index/page.tsx index 41e6c35..f6a5b47 100644 --- a/src/app/(normal)/artworks/animalstudies/index/page.tsx +++ b/src/app/(normal)/artworks/animalstudies/index/page.tsx @@ -86,7 +86,7 @@ export default async function AnimalListPage() { {list.map((a) => (
  • ; - }> + }>, ) { const bySlug = new Map(tagsForFilter.map((t) => [t.slug, t])); const out = new Set(selectedSlugs); @@ -33,7 +33,11 @@ function expandSelectedWithChildren( return Array.from(out); } -export default async function AnimalStudiesPage({ searchParams }: { searchParams: { tags?: string | string[] } }) { +export default async function AnimalStudiesPage({ + searchParams, +}: { + searchParams: { tags?: string | string[] }; +}) { const { tags } = await searchParams; const selectedTagSlugs = parseTagsParam(tags); @@ -57,28 +61,6 @@ export default async function AnimalStudiesPage({ searchParams }: { searchParams const expandedTagSlugs = expandSelectedWithChildren(selectedTagSlugs, tagsForFilter); - const artworks = await prisma.artwork.findMany({ - where: { - categories: { some: { name: "Animal Studies" } }, - published: true, - ...(expandedTagSlugs.length - ? { tags: { some: { slug: { in: expandedTagSlugs } } } } - : {}), - }, - include: { - file: true, - metadata: true, - tags: true, - variants: true, - colors: { - select: { color: { select: { hex: true } } } - } - }, - orderBy: [{ sortKey: "asc" }, { id: "asc" }], - }); - - // console.log(JSON.stringify(artworks, null, 4)) - return (
    @@ -88,16 +70,14 @@ export default async function AnimalStudiesPage({ searchParams }: { searchParams

    {selectedTagSlugs.length > 0 - ? `Filtered by ${selectedTagSlugs.length} tag${selectedTagSlugs.length === 1 ? "" : "s"}` + ? `Filtered by ${selectedTagSlugs.length} tag${selectedTagSlugs.length === 1 ? "" : "s" + }` : "Browse all published artworks in this category."}

    - +
    - + ); -} \ No newline at end of file +} diff --git a/src/app/(normal)/artworks/page.tsx b/src/app/(normal)/artworks/page.tsx index 2dc0f5a..d00fe53 100644 --- a/src/app/(normal)/artworks/page.tsx +++ b/src/app/(normal)/artworks/page.tsx @@ -1,6 +1,6 @@ -import { PortfolioFilters } from "@/actions/portfolio/getPortfolioArtworksPage"; -import ColorMasonryGallery from "@/components/portfolio/ColorMasonryGallery"; +import type { PortfolioFilters } from "@/actions/portfolio/getPortfolioArtworksPage"; import PortfolioFiltersBar from "@/components/portfolio/PortfolioFiltersBar"; +import PortfolioGallery from "@/components/portfolio/PortfolioGallery"; import { prisma } from "@/lib/prisma"; type SearchParams = { @@ -14,11 +14,11 @@ function parseFilters(sp: SearchParams): 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; + if (Number.isFinite(y) && y > 0) filters.year = y; } const qRaw = sp.q?.trim(); - if (qRaw) (filters as any).q = qRaw; + if (qRaw) filters.q = qRaw; return filters; } @@ -53,12 +53,22 @@ export default async function PortfolioPage({ return (
    -
    -

    Portfolio

    - -
    +
    +
    +

    + Portfolio +

    +

    + Browse all published artworks. +

    +
    - +
    + +
    +
    + +
    ); } diff --git a/src/app/globals.css b/src/app/globals.css index 22f2666..f2b246a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -173,7 +173,7 @@ .dark { /* Inky navy background (clearly not neutral) */ - --background: oklch(0.12 0.035 255); + --background: oklch(15.774% 0.03835 263.588); --foreground: oklch(0.95 0.012 85); /* Surfaces */ diff --git a/src/components/animalStudies/AnimalStudiesGallery.tsx b/src/components/animalStudies/AnimalStudiesGallery.tsx new file mode 100644 index 0000000..9cb11ad --- /dev/null +++ b/src/components/animalStudies/AnimalStudiesGallery.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { AnimalStudiesCursor } from "@/actions/animalStudies/getAnimalStudiesPage"; +import { getAnimalStudiesPage } from "@/actions/animalStudies/getAnimalStudiesPage"; +import JustifiedGallery, { type JustifiedGalleryItem } from "@/components/gallery/JustifiedGallery"; + +export default function AnimalStudiesGallery({ + tagSlugs, +}: { + tagSlugs: string[]; +}) { + const [items, setItems] = useState([]); + const [cursor, setCursor] = useState(null); + const [done, setDone] = useState(false); + const [loading, setLoading] = useState(false); + + const inFlight = useRef(false); + + // Reset when tag filter changes (component key may already remount, but keep it safe) + useEffect(() => { + setItems([]); + setCursor(null); + setDone(false); + setLoading(false); + inFlight.current = false; + }, []); + + const loadMore = useCallback(async () => { + if (inFlight.current || done) return; + inFlight.current = true; + setLoading(true); + + try { + const res = await getAnimalStudiesPage({ + take: 60, + cursor, + tagSlugs, + }); + + setItems((prev) => { + const seen = new Set(prev.map((x) => x.id)); + const next = res.items.filter((x) => !seen.has(x.id)); + return prev.concat(next); + }); + + setCursor(res.nextCursor); + if (!res.nextCursor) setDone(true); + } finally { + setLoading(false); + inFlight.current = false; + } + }, [cursor, done, tagSlugs]); + + useEffect(() => { + void loadMore(); + }, [loadMore]); + + return ( + void loadMore()} + hasMore={!done} + isLoadingMore={loading} + /> + ); +} diff --git a/src/components/artworks/ArtworkThumbGallery.tsx b/src/components/artworks/ArtworkThumbGallery.tsx deleted file mode 100644 index 34a8482..0000000 --- a/src/components/artworks/ArtworkThumbGallery.tsx +++ /dev/null @@ -1,117 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import React from "react"; -import { ArtworkImageCard } from "./ArtworkImageCard"; - -type ArtworkGalleryItem = { - id: string; - name: string; - altText: string | null; - okLabL: number | null; - file: { fileKey: string }; - metadata: { width: number; height: number } | null; - tags: { id: string; name: string }[]; - colors: { color: { hex: string | null } }[]; -}; - -type FitMode = - | { mode: "fixedWidth"; width: number } // height varies - | { mode: "fixedHeight"; height: number }; // width varies - -function getOverlayTextClass(okLabL: number | null | undefined) { - return "text-white"; -} - -function getOverlayBgClass(okLabL: number | null | undefined) { - return "bg-black/45"; -} - -type OpenSheet = "alt" | "tags" | null; - -const BUTTON_BAR_HEIGHT = 36; - -export default function ArtworkThumbGallery({ - items, - hrefBase = "/artworks", - fit = { mode: "fixedWidth", width: 400 }, -}: { - items: ArtworkGalleryItem[]; - hrefBase?: string; - fit?: FitMode; -}) { - const [openSheet, setOpenSheet] = React.useState>({}); - - const toggleSheet = (id: string, which: Exclude) => { - setOpenSheet((prev) => { - const current = prev[id] ?? null; - // toggle off if same, switch if different - return { ...prev, [id]: current === which ? null : which }; - }); - }; - - if (items.length === 0) { - return

    No artworks found.

    ; - } - - return ( -
    - {items.map((a) => { - const textClass = getOverlayTextClass(a.okLabL); - const bgClass = getOverlayBgClass(a.okLabL); - - const w = a.metadata?.width ?? 4; - const h = a.metadata?.height ?? 3; - - const tileStyle: React.CSSProperties = - fit.mode === "fixedWidth" - ? { aspectRatio: `${w} / ${h}` } - : { height: fit.height, aspectRatio: `${w} / ${h}` }; - - const sheet = openSheet[a.id] ?? null; - - return ( -
    -
    - - - {/* Title overlay (restored) */} -
    -
    {a.name}
    -
    - - {/* Bottom reserved bar (if you need it later) */} -
    -
    -
    - ); - })} -
    - ); -} diff --git a/src/components/artworks/ArtworkTimelapseViewer.tsx b/src/components/artworks/ArtworkTimelapseViewer.tsx index c1e62a1..3a58fda 100644 --- a/src/components/artworks/ArtworkTimelapseViewer.tsx +++ b/src/components/artworks/ArtworkTimelapseViewer.tsx @@ -7,7 +7,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import * as React from "react"; +import { useState } from "react"; type Timelapse = { s3Key: string; @@ -25,7 +25,7 @@ export default function ArtworkTimelapseViewer({ artworkName?: string | null; trigger: React.ReactNode; }) { - const [open, setOpen] = React.useState(false); + const [open, setOpen] = useState(false); // IMPORTANT: // This assumes your existing `/api/image/[...key]` can stream arbitrary S3 keys. diff --git a/src/components/artworks/ContextBackButton.tsx b/src/components/artworks/ContextBackButton.tsx index b26eeba..b98aa20 100644 --- a/src/components/artworks/ContextBackButton.tsx +++ b/src/components/artworks/ContextBackButton.tsx @@ -5,8 +5,9 @@ import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; const FROM_TO_PATH: Record = { - portfolio: "/portfolio", - "animal-studies": "/animal-studies", + portfolio: "/artworks", + "animal-studies": "/artworks/animalstudies", + "animal-index": "/artworks/animalstudies/index" }; export function ContextBackButton() { diff --git a/src/components/artworks/TagFilterDialog.tsx b/src/components/artworks/TagFilterDialog.tsx index eb77944..505ff80 100644 --- a/src/components/artworks/TagFilterDialog.tsx +++ b/src/components/artworks/TagFilterDialog.tsx @@ -2,7 +2,7 @@ import { FilterIcon, XIcon } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import * as React from "react"; +import { useEffect, useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -17,6 +17,7 @@ import { import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; +import { Label } from "../ui/label"; type Tag = { id: string; @@ -52,21 +53,20 @@ export default function TagFilterDialog({ const pathname = usePathname(); const searchParams = useSearchParams(); - const [open, setOpen] = React.useState(false); - const [draft, setDraft] = React.useState(() => selectedTagSlugs); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(() => selectedTagSlugs); - React.useEffect(() => { + useEffect(() => { setDraft(selectedTagSlugs); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedTagSlugs.join(",")]); + }, [selectedTagSlugs]); const hasDraft = draft.length > 0; - const selectedSet = React.useMemo(() => new Set(draft), [draft]); + const selectedSet = useMemo(() => new Set(draft), [draft]); - const byId = React.useMemo(() => new Map(tags.map((t) => [t.id, t])), [tags]); + const byId = useMemo(() => new Map(tags.map((t) => [t.id, t])), [tags]); // Build children mapping from the flat list: parentId -> Tag[] - const childrenByParentId = React.useMemo(() => { + const childrenByParentId = useMemo(() => { const map = new Map(); for (const t of tags) { if (!t.parentId) continue; @@ -81,14 +81,14 @@ export default function TagFilterDialog({ return map; }, [tags]); - const rootGroups = React.useMemo(() => { + const rootGroups = useMemo(() => { return tags .filter((t) => t.parentId === null) .slice() .sort(sortTags); }, [tags]); - const orphanChildren = React.useMemo(() => { + const orphanChildren = useMemo(() => { return tags .filter((t) => t.parentId !== null && !byId.has(t.parentId)) .slice() @@ -181,7 +181,7 @@ export default function TagFilterDialog({ return (
    -
    */}
    - + {children.length} sub @@ -206,7 +206,7 @@ export default function TagFilterDialog({ const disabled = parentSelected; return ( - + ); })}
    @@ -240,7 +240,7 @@ export default function TagFilterDialog({ {orphanChildren.map((t) => { const checked = selectedSet.has(t.slug); return ( - + ); })} diff --git a/src/components/commissions/FileDropzone.tsx b/src/components/commissions/FileDropzone.tsx index 6653f00..f3958c2 100644 --- a/src/components/commissions/FileDropzone.tsx +++ b/src/components/commissions/FileDropzone.tsx @@ -44,7 +44,7 @@ export function FileDropzone({ // Allow selecting the same file again later (if user removes and re-adds) if (inputRef.current) inputRef.current.value = ""; }, - [append, files, maxFiles, onFilesSelected] + [append, files, maxFiles, onFilesSelected], ); const handleFiles = React.useCallback( @@ -54,7 +54,7 @@ export function FileDropzone({ if (incoming.length === 0) return; mergeFiles(incoming); }, - [mergeFiles] + [mergeFiles], ); const handleDrop = (e: React.DragEvent) => { @@ -75,6 +75,7 @@ export function FileDropzone({ }; return ( + // biome-ignore lint: lint/a11y/useSemanticElements
    void; + hasMore?: boolean; + isLoadingMore?: boolean; + + // layout tuning + targetRowHeight?: number; // desktop + targetRowHeightMobile?: number; // <640px + maxRowHeight?: number; + maxRowItems?: number; // desktop + maxRowItemsMobile?: number; // <640px + gap?: number; // px + className?: string; +}; + +type RowTile = { + item: JustifiedGalleryItem; + w: number; + h: number; +}; + +function aspectOf(it: JustifiedGalleryItem) { + const w = Math.max(1, it.width); + const h = Math.max(1, it.height); + return w / h; +} + +function normalizeColor(value: string | null | undefined) { + if (!value) return null; + const v = value.trim(); + if (!v) return null; + if (v.startsWith("#") || v.startsWith("rgb") || v.startsWith("hsl")) return v; + const hex = v.replace(/^0x/i, ""); + if (/^[0-9a-fA-F]{3}$/.test(hex) || /^[0-9a-fA-F]{6}$/.test(hex)) { + return `#${hex}`; + } + return v; +} +export default function JustifiedGallery({ + items, + hrefFrom, + hrefBase = "/artworks/single", + showCaption = false, + onLoadMore, + hasMore = false, + isLoadingMore = false, + targetRowHeight = 220, + targetRowHeightMobile = 160, + maxRowHeight = 260, + maxRowItems = 5, + maxRowItemsMobile = 3, + gap = 12, + className, +}: Props) { + const containerRef = useRef(null); + const sentinelRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + // Measure container width (responsive) + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const ro = new ResizeObserver(() => setContainerWidth(el.clientWidth)); + ro.observe(el); + setContainerWidth(el.clientWidth); + + return () => ro.disconnect(); + }, []); + + // Infinite scroll sentinel + useEffect(() => { + if (!onLoadMore || !hasMore) return; + + const el = sentinelRef.current; + if (!el) return; + + const io = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !isLoadingMore) onLoadMore(); + }, + { rootMargin: "900px 0px" }, + ); + + io.observe(el); + return () => io.disconnect(); + }, [onLoadMore, hasMore, isLoadingMore]); + + const rows = useMemo(() => { + if (!containerWidth) return [] as RowTile[][]; + + const isMobile = containerWidth < 640; + const targetH = isMobile ? targetRowHeightMobile : targetRowHeight; + const maxItems = isMobile ? maxRowItemsMobile : maxRowItems; + + const rowTiles: RowTile[][] = []; + let current: Array<{ item: JustifiedGalleryItem; aspect: number }> = []; + let aspectSum = 0; + + const available = containerWidth; + + const flush = () => { + if (current.length === 0) return; + + const gaps = gap * (current.length - 1); + const widthWithoutGaps = Math.max(0, available - gaps); + + // Compute row height so it exactly fills the row width. + const computedH = widthWithoutGaps / aspectSum; + const h = Math.min(computedH, maxRowHeight); + + rowTiles.push( + current.map((x) => ({ + item: x.item, + h, + w: Math.round(x.aspect * h), + })), + ); + + current = []; + aspectSum = 0; + }; + + for (const it of items) { + const a = aspectOf(it); + + current.push({ item: it, aspect: a }); + aspectSum += a; + + // Estimate the row width if we were to keep targetH + const estimatedWidth = aspectSum * targetH + gap * (current.length - 1); + + // If we've filled the row (or reached max items) and have at least 2 tiles, flush. + if ( + (estimatedWidth >= available || current.length >= maxItems) && + current.length > 1 + ) { + flush(); + } + } + + flush(); + return rowTiles; + }, [ + items, + containerWidth, + gap, + targetRowHeight, + targetRowHeightMobile, + maxRowHeight, + maxRowItems, + maxRowItemsMobile, + ]); + + const getRowKey = useCallback((row: RowTile[]) => { + const first = row[0]?.item.id ?? "row"; + const last = row.at(-1)?.item.id ?? "row"; + return `${first}-${last}-${row.length}`; + }, []); + + return ( +
    +
    + {rows.map((row) => ( +
    + {row.map((t) => ( + + ))} +
    + ))} +
    + + {onLoadMore ?
    : null} + {isLoadingMore ? ( +

    Loading…

    + ) : null} +
    + ); +} + +function GalleryTile({ + tile, + hrefBase, + hrefFrom, + showCaption, +}: { + tile: RowTile; + hrefBase: string; + hrefFrom: string; + showCaption: boolean; +}) { + const { item, w, h } = tile; + + const href = `${hrefBase}/${item.id}?from=${encodeURIComponent(hrefFrom)}`; + const src = `/api/image/thumbnail/${item.fileKey}.webp`; + + const style: CSSProperties & { "--dom"?: string } = {}; + const dom = normalizeColor(item.dominantHex); + if (dom) style["--dom"] = dom; + + return ( + + {/* Solid vibrant hover ring (no gradient), driven by --dom. + Using box-shadow is more reliable than border-color overrides. */} +
    + + {item.altText + + {showCaption ? ( +
    +
    + {item.name} +
    +
    + ) : null} + + ); +} diff --git a/src/components/portfolio/ColorMasonryGallery.tsx b/src/components/portfolio/ColorMasonryGallery.tsx deleted file mode 100644 index 083a65a..0000000 --- a/src/components/portfolio/ColorMasonryGallery.tsx +++ /dev/null @@ -1,224 +0,0 @@ -"use client"; - -import * as React from "react"; - -import type { - Cursor, - PortfolioArtworkItem, - PortfolioFilters, -} from "@/actions/portfolio/getPortfolioArtworksPage"; -import { getPortfolioArtworksPage } from "@/actions/portfolio/getPortfolioArtworksPage"; -import { ArtworkImageCard } from "../artworks/ArtworkImageCard"; - -type Placement = { - id: string; - top: number; - left: number; - w: number; - h: number; - 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))) - ); - const colW = Math.floor((containerW - gap * (cols - 1)) / cols); - return { cols, colW: Math.max(1, colW) }; -} - -function packStableMasonry( - items: PortfolioArtworkItem[], - containerW: number, - opts: { gap: number; minColW: number; maxCols: number } -): { placements: Placement[]; height: number } { - const { gap, minColW, maxCols } = opts; - if (containerW <= 0 || items.length === 0) return { placements: [], height: 0 }; - - const { cols, colW } = computeCols(containerW, gap, minColW, maxCols); - const colHeights = Array(cols).fill(0) as number[]; - const placements: Placement[] = []; - - for (const it of items) { - let cBest = 0; - for (let c = 1; c < cols; c++) if (colHeights[c] < colHeights[cBest]) cBest = c; - - const ratio = it.thumbH / it.thumbW; - const h = Math.round(colW * ratio); - - const top = colHeights[cBest]; - const left = cBest * (colW + gap); - - placements.push({ - id: it.id, - top, - left, - w: colW, - h, - dominantHex: it.dominantHex, - }); - - colHeights[cBest] = top + h + gap; - } - - const height = Math.max(...colHeights) - gap; - return { placements, height: Math.max(0, height) }; -} - -function thumbUrl(fileKey: string) { - return `/api/image/resized/${fileKey}.webp`; -} - -function useResizeObserverWidth() { - const ref = React.useRef(null); - const [w, setW] = React.useState(0); - - React.useEffect(() => { - const el = ref.current; - if (!el) return; - const ro = new ResizeObserver(([e]) => setW(Math.floor(e.contentRect.width))); - ro.observe(el); - return () => ro.disconnect(); - }, []); - - return { ref, w }; -} - -export default function ColorMasonryGallery({ - filters, -}: { - filters: PortfolioFilters; -}) { - const { ref: containerRef, w: containerW } = useResizeObserverWidth(); - - const [items, setItems] = React.useState([]); - const [done, setDone] = React.useState(false); - const [loading, setLoading] = React.useState(false); - - const inFlight = React.useRef(false); - const doneRef = React.useRef(false); - doneRef.current = done; - - const cursorRef = React.useRef(null); - - React.useEffect(() => { - setItems([]); - setDone(false); - doneRef.current = false; - inFlight.current = false; - cursorRef.current = null; - }, [filters]); - - const loadMore = React.useCallback(async () => { - if (inFlight.current || doneRef.current) return 0; - inFlight.current = true; - setLoading(true); - - try { - const data = await getPortfolioArtworksPage({ - take: 60, - cursor: cursorRef.current, - filters, - onlyPublished: true, - }); - - // Defensive dedupe: prevents accidental repeats from any future cursor edge case - setItems((prev) => { - const seen = new Set(prev.map((x) => x.id)); - const next = data.items.filter((x) => !seen.has(x.id)); - return prev.concat(next); - }); - - cursorRef.current = data.nextCursor; - if (!data.nextCursor) setDone(true); - - return data.items.length; - } finally { - setLoading(false); - inFlight.current = false; - } - }, [filters]); - - React.useEffect(() => { - void loadMore(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loadMore]); - - const sentinelRef = React.useRef(null); - React.useEffect(() => { - const sentinel = sentinelRef.current; - if (!sentinel) return; - - const io = new IntersectionObserver( - (entries) => { - if (entries.some((e) => e.isIntersecting)) void loadMore(); - }, - { rootMargin: "900px 0px", threshold: 0.01 } - ); - - io.observe(sentinel); - return () => io.disconnect(); - }, [loadMore]); - - const GAP = 14; - const MIN_COL_W = 260; - const MAX_COLS = 6; - - const { placements, height } = React.useMemo(() => { - return packStableMasonry(items, containerW, { - gap: GAP, - minColW: MIN_COL_W, - maxCols: MAX_COLS, - }); - }, [items, containerW]); - - const itemsById = React.useMemo(() => new Map(items.map((it) => [it.id, it])), [items]); - - return ( -
    -
    - {placements.map((p) => { - const it = itemsById.get(p.id); - if (!it) return null; - - return ( -
    - {/*
    */} - -
    - //
    - ); - })} -
    - - {!done &&
    } - {loading &&

    Loading…

    } - {!loading && done && items.length === 0 && ( -

    No artworks to display

    - )} -
    - ); -} diff --git a/src/components/portfolio/PortfolioFiltersBar.tsx b/src/components/portfolio/PortfolioFiltersBar.tsx index ba36a7e..2d25bde 100644 --- a/src/components/portfolio/PortfolioFiltersBar.tsx +++ b/src/components/portfolio/PortfolioFiltersBar.tsx @@ -1,7 +1,22 @@ "use client"; +import { FilterIcon, XIcon } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import * as React from "react"; +import { useEffect, useState } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; function setParam(params: URLSearchParams, key: string, value?: string | null) { if (!value) params.delete(key); @@ -16,118 +31,119 @@ export default function PortfolioFiltersBar({ years = [] }: { years?: number[] } 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); + const [open, setOpen] = useState(false); + const [draftYear, setDraftYear] = useState(yearParam); + const [draftQ, setDraftQ] = useState(qParam); - // Sync input when navigating back/forward (URL -> input) - React.useEffect(() => { - setQ(qParam); - }, [qParam]); + useEffect(() => { + setDraftYear(yearParam); + setDraftQ(qParam); + }, [yearParam, qParam]); - const pushParams = React.useCallback( - (mutate: (next: URLSearchParams) => void) => { - const next = new URLSearchParams(sp.toString()); - mutate(next); + const activeCount = (yearParam !== "all" ? 1 : 0) + (qParam.trim().length ? 1 : 0); - 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 clearAll = () => { + setDraftYear("all"); + setDraftQ(""); }; - const submitSearch = (value: string) => { - const trimmed = value.trim(); - pushParams((next) => { - setParam(next, "q", trimmed.length ? trimmed : null); - }); - }; + const apply = () => { + const next = new URLSearchParams(sp.toString()); - const clear = () => { - setQ(""); - pushParams((next) => { - next.delete("year"); - next.delete("q"); - }); + const year = draftYear.trim(); + if (!year || year === "all") next.delete("year"); + else setParam(next, "year", year); + + const q = draftQ.trim(); + if (!q) next.delete("q"); + else setParam(next, "q", q); + + const qs = next.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + setOpen(false); }; return ( -
    -
    -
    Year
    -
    - + + + + - {years.map((y) => { - const active = yearParam === String(y); - return ( - - ); - })} + + Clear + + ) : null} + +

    + Filter by year and search by artwork name or tags. +

    + + + + + +
    +
    + + +
    + +
    + + setDraftQ(e.target.value)} + placeholder="Search name or tags" + inputMode="search" + className="h-11" + /> +
    +
    +
    + + + +
    + +
    -
    - -
    { - e.preventDefault(); - submitSearch(q); - }} - > -
    Search (by name or tags)
    - -
    - setQ(e.target.value)} - placeholder="e.g. lizard, monk, fantasy" - inputMode="search" - /> - - - - -
    -
    -
    + + ); } diff --git a/src/components/portfolio/PortfolioGallery.tsx b/src/components/portfolio/PortfolioGallery.tsx new file mode 100644 index 0000000..08657e5 --- /dev/null +++ b/src/components/portfolio/PortfolioGallery.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { + Cursor, + PortfolioArtworkItem, + PortfolioFilters, +} from "@/actions/portfolio/getPortfolioArtworksPage"; +import { getPortfolioArtworksPage } from "@/actions/portfolio/getPortfolioArtworksPage"; +import JustifiedGallery, { + type JustifiedGalleryItem, +} from "@/components/gallery/JustifiedGallery"; + +export default function PortfolioGallery({ + filters, +}: { + filters: PortfolioFilters; +}) { + const { year, albumId, q } = filters; + + const queryFilters = useMemo( + () => ({ year, albumId, q }), + [year, albumId, q] + ); + const resetKey = useMemo( + () => `${year ?? ""}|${albumId ?? ""}|${q ?? ""}`, + [year, albumId, q] + ); + + const [items, setItems] = useState([]); + const [done, setDone] = useState(false); + const [loading, setLoading] = useState(false); + + const inFlight = useRef(false); + const doneRef = useRef(false); + doneRef.current = done; + const cursorRef = useRef(null); + + useEffect(() => { + if (resetKey == null) return; + setItems([]); + setDone(false); + doneRef.current = false; + inFlight.current = false; + cursorRef.current = null; + }, [resetKey]); + + const loadMore = useCallback(async () => { + if (inFlight.current || doneRef.current) return 0; + inFlight.current = true; + setLoading(true); + + try { + const data = await getPortfolioArtworksPage({ + take: 60, + cursor: cursorRef.current, + filters: queryFilters, + onlyPublished: true, + }); + + // Defensive dedupe + setItems((prev) => { + const seen = new Set(prev.map((x) => x.id)); + const next = data.items.filter((x) => !seen.has(x.id)); + return prev.concat(next); + }); + + cursorRef.current = data.nextCursor; + if (!data.nextCursor) setDone(true); + + return data.items.length; + } finally { + setLoading(false); + inFlight.current = false; + } + }, [queryFilters]); + + useEffect(() => { + void loadMore(); + }, [loadMore]); + + const galleryItems: JustifiedGalleryItem[] = items.map((it) => ({ + id: it.id, + name: it.name, + altText: it.altText, + fileKey: it.fileKey, + width: it.thumbW, + height: it.thumbH, + dominantHex: it.dominantHex, + })); + + useEffect(() => { + if (items.length === 0) return; + // Debug: inspect dominantHex values coming from the server. + console.log( + "[PortfolioGallery] dominantHex sample", + items.slice(0, 5).map((it) => ({ + id: it.id, + dominantHex: it.dominantHex, + })) + ); + }, [items]); + + if (!loading && done && galleryItems.length === 0) { + return ( +

    + No artworks to display +

    + ); + } + + return ( +
    + void loadMore()} + hasMore={!done} + isLoadingMore={loading} + /> +
    + ); +} diff --git a/src/schemas/commissionOrder.ts b/src/schemas/commissionOrder.ts index 74c6fc7..e4565c5 100644 --- a/src/schemas/commissionOrder.ts +++ b/src/schemas/commissionOrder.ts @@ -4,7 +4,7 @@ export const commissionOrderSchema = z.object({ typeId: z.string().min(1, "Please select a type"), optionId: z.string().min(1, "Please choose a base option"), extraIds: z.array(z.string()).optional(), - customFields: z.record(z.string(), z.any()).optional(), + customFields: z.record(z.string(), z.unknown()).optional(), customerName: z.string().min(2, "Enter your name"), customerEmail: z.email("Invalid email"), customerSocials: z.string().optional(),