Unify portfolio and animal studies galleries
This commit is contained in:
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user