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

@ -55,9 +55,9 @@ export default async function PortfolioPage({
<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>
<PortfolioFiltersBar years={years} />
</div> </div>
<PortfolioFiltersBar years={years} />
<ColorMasonryGallery filters={filters} /> <ColorMasonryGallery filters={filters} />
</div> </div>
); );

View File

@ -1,10 +1,11 @@
import ArtworkMetaCard from "@/components/artworks/ArtworkMetaCard";
import { ContextBackButton } from "@/components/artworks/ContextBackButton";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LayersIcon, QuoteIcon, TagIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
export default async function SingleArtworkPage({ params }: { params: { id: string } }) { export default async function SingleArtworkPage({ params }: { params: { id: string }; searchParams: Record<string, string | string[] | undefined>; }) {
const { id } = await params; const { id } = await params;
const artwork = await prisma.artwork.findUnique({ const artwork = await prisma.artwork.findUnique({
where: { where: {
@ -36,10 +37,15 @@ export default async function SingleArtworkPage({ params }: { params: { id: stri
return ( return (
<div className="px-8 py-4"> <div className="px-8 py-4">
<div className="relative w-full min-h-10 flex items-center mb-4">
<div className="z-10"><ContextBackButton /></div>
{artwork.name ? (
<div className="pointer-events-none absolute left-1/2 -translate-x-1/2 text-center">
<div className="pointer-events-auto"><h1 className="text-2xl font-bold mb-4 py-4">{artwork.name}</h1></div>
</div>
) : null}
</div>
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div>
<h1 className="text-2xl font-bold mb-4 pb-4">{artwork.name}</h1>
</div>
<div className="group rounded-lg border overflow-hidden hover:shadow-lg transition-shadow bg-background relative"> <div className="group rounded-lg border overflow-hidden hover:shadow-lg transition-shadow bg-background relative">
<div className="relative w-full bg-muted items-center justify-center" <div className="relative w-full bg-muted items-center justify-center"
style={{ aspectRatio: "4 / 3" }} style={{ aspectRatio: "4 / 3" }}
@ -54,109 +60,23 @@ export default async function SingleArtworkPage({ params }: { params: { id: stri
className={cn("object-cover transition duration-300")} className={cn("object-cover transition duration-300")}
/> />
</Link> </Link>
{/* {image.nsfw && (
<div className="absolute top-1 left-1 z-10 flex gap-1">
<TagBadge
label="NSFW"
variant="destructive"
icon={<EyeOffIcon size={12} />}
className="text-xs px-2 py-0.5 inline-flex items-center"
/>
</div>
)} */}
</div> </div>
{/* {!isLarge && (
<div className="p-4">
<h2 className="text-lg font-semibold truncate text-center">{image.imageName}</h2>
</div>
)} */}
</div> </div>
<div <div
className="rounded-lg" className="rounded-lg"
style={{ style={{
background: `linear-gradient(135deg, ${gradientColors})`, background: `linear-gradient(135deg, ${gradientColors})`,
}}> }}
<div >
className="overflow-hidden border rounded-lg m-0.5 p-4 shadow w-full max-w-xl space-y-3 bg-white dark:bg-black" <ArtworkMetaCard
> gradientColors={gradientColors}
{artwork.altText && ( altText={artwork.altText}
<div className="flex items-center gap-2"> description={artwork.description}
<TagIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-px" /> categories={artwork.categories}
<span className="text-sm text-muted-foreground">{artwork.altText}</span> tags={artwork.tags}
</div>
)}
{artwork.description && (
<div className="flex items-center gap-2">
<QuoteIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-px" />
<span className="text-sm text-muted-foreground">{artwork.description}</span>
</div>
)}
{/* {creationLabel && (
<div className="flex items-center gap-2">
<CalendarDaysIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-[1px]" />
<span className="text-sm text-muted-foreground">{creationLabel}</span>
</div>
)}
{album && (
<div className="flex items-center gap-2 flex-wrap">
<ImagesIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-[1px]" />
<Link href={`/galleries/${album.gallery?.slug}/${album.slug}`} className="text-sm underline">
{album.name}
</Link>
</div>
)} */}
{artwork.categories.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<LayersIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-1px" />
{artwork.categories.map((cat) => (
<Link
key={cat.id}
href={`/categories/${cat.id}`}
className="text-sm underline"
>
{cat.name}
</Link>
))}
</div>
)}
{artwork.tags.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<TagIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-1px" />
{artwork.tags.map((tag) => (
<Link
key={tag.id}
href={`/tags/${tag.id}`}
className="text-sm underline"
>
{tag.name}
</Link>
))}
</div>
)}
</div>
</div>
{/* <ImageCard image={image} gallerySlug={image.album?.gallery?.slug} albumSlug={image.album?.slug} size="large" />
<section className="py-8 flex flex-col gap-4">
{image.artist && <ArtistInfoBox artist={image.artist} />}
<ImageMetadataBox
album={image.album}
categories={image.categories}
tags={image.tags}
creationDate={image.creationDate}
creationYear={image.creationYear}
creationMonth={image.creationMonth}
altText={image.altText}
description={image.description}
/> />
</section> */} </div>
</div> </div>
</div > </div >
); );
} }

View File

@ -16,7 +16,7 @@ export default function NormalLayout({
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60"> <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
<Header /> <Header />
</header> </header>
<main className="container mx-auto"> <main>
{children} {children}
</main> </main>
<footer className="mt-auto p-4 h-14 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 "> <footer className="mt-auto p-4 h-14 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 ">

View File

@ -143,10 +143,6 @@
@apply list-decimal pl-6 my-4; @apply list-decimal pl-6 my-4;
} }
.markdown li {
/* @apply mb-1; */
}
.markdown blockquote { .markdown blockquote {
@apply border-l-4 pl-4 italic text-muted-foreground my-4; @apply border-l-4 pl-4 italic text-muted-foreground my-4;
} }
@ -175,9 +171,9 @@
@apply line-through text-muted-foreground; @apply line-through text-muted-foreground;
} }
div:hover { /* div:hover {
border-color: var(--hover-border-color); border-color: var(--hover-border-color);
} } */
@layer base { @layer base {
* { * {

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

View File

@ -1,7 +1,5 @@
"use client"; "use client";
import Image from "next/image";
import Link from "next/link";
import * as React from "react"; import * as React from "react";
import type { import type {
@ -10,6 +8,7 @@ import type {
PortfolioFilters, PortfolioFilters,
} from "@/actions/portfolio/getPortfolioArtworksPage"; } from "@/actions/portfolio/getPortfolioArtworksPage";
import { getPortfolioArtworksPage } from "@/actions/portfolio/getPortfolioArtworksPage"; import { getPortfolioArtworksPage } from "@/actions/portfolio/getPortfolioArtworksPage";
import { ArtworkImageCard } from "../artworks/ArtworkImageCard";
type Placement = { type Placement = {
id: string; id: string;
@ -188,8 +187,6 @@ export default function ColorMasonryGallery({
const it = itemsById.get(p.id); const it = itemsById.get(p.id);
if (!it) return null; if (!it) return null;
const href = `/artworks/single/${it.id}`;
return ( return (
<div <div
key={p.id} key={p.id}
@ -200,27 +197,19 @@ export default function ColorMasonryGallery({
height: p.h, height: p.h,
}} }}
> >
<Link {/* <div style={{ ["--dom" as any]: p.dominantHex }} className="h-full w-full"> */}
href={href} <ArtworkImageCard
className={[ mode="tile"
"block w-full h-full overflow-hidden rounded-md", href={`/artworks/single/${it.id}?from=portfolio`}
"border border-transparent", src={thumbUrl(it.fileKey)}
"transition-colors duration-150", alt={it.altText ?? it.name}
"hover:border-(--dom)", width={Math.max(1, it.thumbW)}
].join(" ")} height={Math.max(1, it.thumbH)}
style={{ ["--dom" as any]: p.dominantHex }} style={{ ["--dom" as any]: p.dominantHex }}
aria-label={`Open ${it.name}`} className="w-full h-full rounded-md"
> />
<Image
src={thumbUrl(it.fileKey)}
alt={it.altText ?? it.name}
width={Math.max(1, it.thumbW)}
height={Math.max(1, it.thumbH)}
className="w-full h-full object-cover select-none"
loading="lazy"
/>
</Link>
</div> </div>
// </div>
); );
})} })}
</div> </div>