Unify portfolio and animal studies galleries

This commit is contained in:
2026-01-31 01:18:46 +01:00
parent 0de3eed5f1
commit 96efd4c942
17 changed files with 800 additions and 528 deletions

View File

@ -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<Record<string, OpenSheet>>({});
const toggleSheet = (id: string, which: Exclude<OpenSheet, null>) => {
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 <p className="text-muted-foreground italic">No artworks found.</p>;
}
return (
<div
className="grid gap-3.5 justify-center"
style={{
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
}}
>
{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 (
<div key={a.id} className="w-full" style={tileStyle}>
<div className="relative h-full w-full">
<ArtworkImageCard
mode="tile"
href={`${hrefBase}/single/${a.id}?from=animal-studies`}
src={`/api/image/resized/${a.file.fileKey}.webp`}
alt={a.altText ?? a.name ?? "Artwork"}
width={a.metadata?.width ?? 0}
height={a.metadata?.height ?? 0}
aspectRatio={`${w} / ${h}`}
className="h-full w-full rounded-md"
imageClassName="object-cover"
style={{ ["--dom" as any]: a.colors[0]?.color?.hex ?? "#999999", }}
sizes="(min-width: 1280px) 20vw, (min-width: 1024px) 25vw, (min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw"
/>
{/* Title overlay (restored) */}
<div
className={cn(
"pointer-events-none absolute left-0 right-0 top-0 px-3 py-2",
bgClass,
"backdrop-blur-[1px]"
)}
>
<div className={cn("truncate text-sm font-medium", textClass)}>{a.name}</div>
</div>
{/* Bottom reserved bar (if you need it later) */}
<div
className="absolute left-0 right-0 bottom-0 z-20 flex items-center justify-between px-2"
style={{ height: BUTTON_BAR_HEIGHT }}
/>
</div>
</div>
);
})}
</div>
);
}

View File

@ -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.

View File

@ -5,8 +5,9 @@ import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
const FROM_TO_PATH: Record<string, string> = {
portfolio: "/portfolio",
"animal-studies": "/animal-studies",
portfolio: "/artworks",
"animal-studies": "/artworks/animalstudies",
"animal-index": "/artworks/animalstudies/index"
};
export function ContextBackButton() {

View File

@ -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<string[]>(() => selectedTagSlugs);
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<string[]>(() => 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<string, Tag[]>();
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 (
<div key={p.id} className="rounded-lg border p-4">
<div className="flex items-start justify-between gap-3">
<label className="flex cursor-pointer items-center gap-3">
<Label className="flex cursor-pointer items-center gap-3">
<Checkbox
checked={parentSelected}
onCheckedChange={(v) => onToggleParent(p, Boolean(v))}
@ -192,7 +192,7 @@ export default function TagFilterDialog({
{children.length ? "Parent tag" : "Tag"}
</div> */}
</div>
</label>
</Label>
<Badge variant={parentSelected ? "default" : "outline"}>
{children.length} sub
@ -206,7 +206,7 @@ export default function TagFilterDialog({
const disabled = parentSelected;
return (
<label
<Label
key={c.id}
className={cn(
"flex items-center gap-3 rounded-md border px-3 py-2",
@ -220,7 +220,7 @@ export default function TagFilterDialog({
onCheckedChange={(v) => onToggleChild(c.slug, Boolean(v))}
/>
<span className="min-w-0 truncate text-sm">{c.name}</span>
</label>
</Label>
);
})}
</div>
@ -240,7 +240,7 @@ export default function TagFilterDialog({
{orphanChildren.map((t) => {
const checked = selectedSet.has(t.slug);
return (
<label
<Label
key={t.id}
className="flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2 hover:bg-muted/50"
>
@ -249,7 +249,7 @@ export default function TagFilterDialog({
onCheckedChange={(v) => onToggleChild(t.slug, Boolean(v))}
/>
<span className="min-w-0 truncate text-sm">{t.name}</span>
</label>
</Label>
);
})}
</div>