Refactor galleries and single page

This commit is contained in:
2025-12-30 19:37:33 +01:00
parent 8143f90bfc
commit 21faef78ee
9 changed files with 301 additions and 152 deletions

View File

@ -0,0 +1,131 @@
import Image from "next/image";
import Link from "next/link";
type ClampOpts = { maxIntrinsic: number };
/**
* Clamps intrinsic w/h while preserving aspect ratio.
* This helps prevent Next/Image from treating a tiny thumbnail as a large asset.
*/
function clampDims(w: number, h: number, { maxIntrinsic }: ClampOpts) {
const W = Math.max(1, w);
const H = Math.max(1, h);
const maxSide = Math.max(W, H);
if (maxSide <= maxIntrinsic) return { w: W, h: H };
const scale = maxIntrinsic / maxSide;
return {
w: Math.max(1, Math.round(W * scale)),
h: Math.max(1, Math.round(H * scale)),
};
}
export type ArtworkImageCardProps = {
href: string;
src: string;
alt: string;
/**
* Provide real dimensions if you have them.
* For tile mode, we still pass intrinsic sizes, but clamp them.
*/
width?: number;
height?: number;
/**
* tile: uses `fill` + container aspect ratio (best for masonry/grid)
* hero: uses width/height when available (best for single page)
*/
mode?: "tile" | "hero";
/**
* For tile mode only.
* Example: "4 / 3" or `${w} / ${h}`
*/
aspectRatio?: string;
className?: string;
imageClassName?: string;
/**
* Controls intrinsic clamp for thumbnails:
* you asked: “max the double of 160” => 320 default.
*/
maxThumbIntrinsic?: number;
sizes?: string;
priority?: boolean;
style?: React.CSSProperties;
linkClassName?: string;
};
export function ArtworkImageCard({
href,
src,
alt,
width,
height,
mode = "tile",
aspectRatio = "4 / 3",
className,
imageClassName,
maxThumbIntrinsic = 320,
sizes,
priority,
style,
}: ArtworkImageCardProps) {
const w = width ?? 0;
const h = height ?? 0;
const isHero = mode === "hero";
const hasDims = w > 0 && h > 0;
// Clamp only for non-hero (thumbnail-like) usage
const clamped = !isHero && hasDims ? clampDims(w, h, { maxIntrinsic: maxThumbIntrinsic }) : null;
return (
<div
style={style}
className={[
"group rounded-lg overflow-hidden bg-background relative",
// IMPORTANT: put the border here so the --dom hover works again
"border border-transparent transition-colors duration-150 hover:border-(--dom)",
"hover:shadow-lg transition-shadow",
className ?? "",
].join(" ")}
>
<div
className="relative w-full bg-muted items-center justify-center"
style={mode === "tile" ? { aspectRatio } : undefined}
>
<Link href={href} className="block">
{isHero ? (
<Image
src={src}
alt={alt}
// If we have dimensions, use them; otherwise fallback to fill.
width={hasDims ? w : undefined}
height={hasDims ? h : undefined}
fill={!hasDims}
className={["object-cover transition duration-300", imageClassName ?? ""].join(" ")}
sizes={sizes}
priority={priority}
/>
) : (
<Image
src={src}
alt={alt}
// Pass clamped intrinsic sizes when possible
width={clamped?.w ?? 320}
height={clamped?.h ?? 240}
className={["w-full h-full object-cover select-none", imageClassName ?? ""].join(" ")}
loading={priority ? "eager" : "lazy"}
sizes={sizes}
priority={priority}
/>
)}
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,72 @@
import { LayersIcon, QuoteIcon, TagIcon } from "lucide-react";
export default function ArtworkMetaCard({
gradientColors,
altText,
description,
categories,
tags,
}: {
gradientColors: string;
altText?: string | null;
description?: string | null;
categories: { id: string; name: string }[];
tags: { id: string; name: string }[];
}) {
const hasData =
Boolean(altText) ||
Boolean(description) ||
categories.length > 0 ||
tags.length > 0;
if (!hasData) return null;
return (
<div
className="w-full max-w-xl rounded-xl p-px"
style={{ background: `linear-gradient(135deg, ${gradientColors})` }}
>
<div className="rounded-[11px] border bg-background p-4 shadow-sm space-y-3">
{altText && (
<div className="flex items-start gap-2">
<TagIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-0.5" />
<span className="text-sm text-muted-foreground">{altText}</span>
</div>
)}
{description && (
<div className="flex items-start gap-2">
<QuoteIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-0.5" />
<span className="text-sm text-muted-foreground">{description}</span>
</div>
)}
{categories.length > 0 && (
<div className="flex items-start gap-2 flex-wrap">
<LayersIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-0.5" />
{categories.map((cat) => (
<div key={cat.id} className="text-sm underline">
{/* <Link key={cat.id} href={`/categories/${cat.id}`} className="text-sm underline"> */}
{cat.name}
{/* </Link> */}
</div>
))}
</div>
)}
{tags.length > 0 && (
<div className="flex items-start gap-2 flex-wrap">
<TagIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-0.5" />
{tags.map((tag) => (
<div key={tag.id} className="text-sm underline">
{/* <Link key={tag.id} href={`/tags/${tag.id}`} className="text-sm underline"> */}
{tag.name}
{/* </Link> */}
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -1,9 +1,8 @@
"use client";
import { cn } from "@/lib/utils";
import Image from "next/image";
import Link from "next/link";
import React from "react";
import { ArtworkImageCard } from "./ArtworkImageCard";
type ArtworkGalleryItem = {
id: string;
@ -77,37 +76,36 @@ export default function ArtworkThumbGallery({
return (
<div key={a.id} className="w-full" style={tileStyle}>
<div className="relative h-full w-full overflow-hidden rounded-md border">
<Link href={`${hrefBase}/single/${a.id}`} className="block h-full w-full">
<Image
src={`/api/image/thumbnail/${a.file.fileKey}.webp`}
alt={a.altText ?? a.name ?? "Artwork"}
fill
className="object-cover"
loading="lazy"
sizes="(min-width: 1280px) 20vw, (min-width: 1024px) 25vw, (min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw"
/>
</Link>
<div className="relative h-full w-full">
<ArtworkImageCard
mode="tile"
href={`${hrefBase}/single/${a.id}?from=animal-studies`}
src={`/api/image/thumbnail/${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"
sizes="(min-width: 1280px) 20vw, (min-width: 1024px) 25vw, (min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw"
/>
{/* Title overlay */}
{/* Title overlay (restored) */}
<div
className={cn(
"absolute left-0 right-0 top-0 px-3 py-2",
"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 className={cn("truncate text-sm font-medium", textClass)}>{a.name}</div>
</div>
{/* Bottom button bar (reserved space) */}
{/* 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

@ -0,0 +1,43 @@
"use client";
import { ArrowLeftIcon } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
const FROM_TO_PATH: Record<string, string> = {
portfolio: "/portfolio",
"animal-studies": "/animal-studies",
};
export function ContextBackButton() {
const router = useRouter();
const sp = useSearchParams();
const from = sp.get("from") ?? "";
const target = FROM_TO_PATH[from];
if (!target) return null;
return (
<div className="w-full max-w-xl">
<Link
href={target}
className={[
"inline-flex items-center gap-2 rounded-md border px-3 py-2",
"text-sm text-muted-foreground hover:text-foreground",
"hover:bg-muted transition-colors",
].join(" ")}
onClick={(e) => {
// If user has real history, prefer back; otherwise use link target.
// (Prevents “dead end” if they opened single page directly in new tab.)
if (window.history.length > 1) {
e.preventDefault();
router.back();
}
}}
>
<ArrowLeftIcon className="h-4 w-4" />
Back
</Link>
</div>
);
}