4 Commits

5 changed files with 143 additions and 49 deletions

View File

@ -8,7 +8,12 @@ import { PlayCircle } 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 }; searchParams: Record<string, string | string[] | undefined>; }) { 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: {
@ -24,34 +29,44 @@ export default async function SingleArtworkPage({ params }: { params: { id: stri
tags: true, tags: true,
variants: true, variants: true,
timelapse: { where: { enabled: true } }, timelapse: { where: { enabled: true } },
} },
}) });
if (!artwork) return <div>Artwork with this ID could not be found</div> if (!artwork) return <div>Artwork with this ID could not be found</div>;
const { width, height } = artwork.variants.find((v) => v.type === "resized") ?? { width: 0, height: 0 } const { width, height } = artwork.variants.find(
(v) => v.type === "resized",
) ?? { width: 0, height: 0 };
const colors = const colors =
artwork.colors?.map((c) => c.color?.hex).filter((hex): hex is string => Boolean(hex)) ?? [] artwork.colors
?.map((c) => c.color?.hex)
.filter((hex): hex is string => Boolean(hex)) ?? [];
const gradientColors = colors.length const gradientColors = colors.length
? colors.join(", ") ? colors.join(", ")
: "rgba(0,0,0,0.1), rgba(0,0,0,0.03)" : "rgba(0,0,0,0.1), rgba(0,0,0,0.03)";
return ( return (
<div className="px-8 py-4"> <div className="px-4 sm:px-8 py-4">
<div className="relative w-full min-h-10 flex items-center mb-4"> <div className="relative w-full min-h-10 flex items-center mb-4">
<div className="z-10"><ContextBackButton /></div> <div className="z-10 hidden sm:block">
<ContextBackButton />
</div>
{artwork.name ? ( {artwork.name ? (
<div className="pointer-events-none absolute left-1/2 -translate-x-1/2 text-center"> <div className="w-full text-center sm:pointer-events-none sm:absolute sm:left-1/2 sm:-translate-x-1/2">
<div className="pointer-events-auto"><h1 className="text-2xl font-bold mb-4 py-4">{artwork.name}</h1></div> <div className="sm:pointer-events-auto">
<h1 className="text-xl sm:text-2xl font-bold mb-2 sm:mb-4 py-2 sm:py-4 px-2 sm:px-0 wrap-break-word">
{artwork.name}
</h1>
</div>
</div> </div>
) : null} ) : null}
</div> </div>
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<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" }}
> >
<Link href={`/raw/${artwork.id}`}> <Link href={`/raw/${artwork.id}`}>
@ -94,7 +109,10 @@ export default async function SingleArtworkPage({ params }: { params: { id: stri
tags={artwork.tags} tags={artwork.tags}
/> />
</div> </div>
<div className="w-full flex justify-center sm:hidden">
<ContextBackButton className="mx-auto flex justify-center" />
</div>
</div> </div>
</div > </div>
); );
} }

View File

@ -2,9 +2,12 @@ import { Badge } from "@/components/ui/badge";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
const statusStyles: Record<string, string> = { const statusStyles: Record<string, string> = {
ACCEPTED: "bg-sky-500/15 text-sky-300 border-sky-500/30", ACCEPTED:
INPROGRESS: "bg-amber-500/15 text-amber-300 border-amber-500/30", "bg-sky-500/20 text-sky-700 border-sky-500/40 dark:bg-sky-500/15 dark:text-sky-300 dark:border-sky-500/30",
COMPLETED: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30", INPROGRESS:
"bg-amber-500/20 text-amber-700 border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30",
COMPLETED:
"bg-emerald-500/20 text-emerald-700 border-emerald-500/40 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30",
}; };
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {

View File

@ -10,7 +10,7 @@ const FROM_TO_PATH: Record<string, string> = {
"animal-index": "/artworks/animalstudies/index" "animal-index": "/artworks/animalstudies/index"
}; };
export function ContextBackButton() { export function ContextBackButton({ className }: { className?: string }) {
const router = useRouter(); const router = useRouter();
const sp = useSearchParams(); const sp = useSearchParams();
const from = sp.get("from") ?? ""; const from = sp.get("from") ?? "";
@ -19,7 +19,7 @@ export function ContextBackButton() {
if (!target) return null; if (!target) return null;
return ( return (
<div className="w-full max-w-xl"> <div className={["w-full max-w-xl", className].filter(Boolean).join(" ")}>
<Link <Link
href={target} href={target}
className={[ className={[

View File

@ -44,6 +44,7 @@ type Props = {
maxRowItems?: number; // desktop maxRowItems?: number; // desktop
maxRowItemsMobile?: number; // <640px maxRowItemsMobile?: number; // <640px
gap?: number; // px gap?: number; // px
debug?: boolean;
className?: string; className?: string;
}; };
@ -84,6 +85,7 @@ export default function JustifiedGallery({
maxRowItems = 5, maxRowItems = 5,
maxRowItemsMobile = 3, maxRowItemsMobile = 3,
gap = 12, gap = 12,
debug = false,
className, className,
}: Props) { }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@ -125,7 +127,12 @@ export default function JustifiedGallery({
const isMobile = containerWidth < 640; const isMobile = containerWidth < 640;
const targetH = isMobile ? targetRowHeightMobile : targetRowHeight; const targetH = isMobile ? targetRowHeightMobile : targetRowHeight;
const maxItems = isMobile ? maxRowItemsMobile : maxRowItems; const maxItems = (() => {
if (containerWidth < 480) return Math.min(2, maxRowItemsMobile);
if (containerWidth < 720) return Math.min(3, maxRowItems);
if (containerWidth < 1024) return Math.min(4, maxRowItems);
return maxRowItems;
})();
const rowTiles: RowTile[][] = []; const rowTiles: RowTile[][] = [];
let current: Array<{ item: JustifiedGalleryItem; aspect: number }> = []; let current: Array<{ item: JustifiedGalleryItem; aspect: number }> = [];
@ -155,7 +162,10 @@ export default function JustifiedGallery({
aspectSum = 0; aspectSum = 0;
}; };
for (const it of items) { const workingItems = items.slice();
for (let i = 0; i < workingItems.length; i += 1) {
const it = workingItems[i];
const a = aspectOf(it); const a = aspectOf(it);
current.push({ item: it, aspect: a }); current.push({ item: it, aspect: a });
@ -169,7 +179,46 @@ export default function JustifiedGallery({
(estimatedWidth >= available || current.length >= maxItems) && (estimatedWidth >= available || current.length >= maxItems) &&
current.length > 1 current.length > 1
) { ) {
flush(); const gaps = gap * (current.length - 1);
const widthWithoutGaps = Math.max(0, available - gaps);
const computedH = widthWithoutGaps / aspectSum;
// If the row would be shorter than maxRowHeight, reduce items and flush.
if (computedH < maxRowHeight && current.length > 1) {
const last = current.pop();
if (last) {
aspectSum -= last.aspect;
}
const limit = widthWithoutGaps / maxRowHeight;
let swapped = false;
for (let look = 1; look <= 2; look += 1) {
const idx = i + look;
if (idx >= workingItems.length) break;
const candidate = workingItems[idx];
const candidateAspect = aspectOf(candidate);
if (aspectSum + candidateAspect <= limit) {
workingItems[idx] = last?.item ?? candidate;
current.push({ item: candidate, aspect: candidateAspect });
aspectSum += candidateAspect;
swapped = true;
break;
}
}
flush();
if (!swapped && last) {
current = [last];
aspectSum = last.aspect;
} else {
current = [];
aspectSum = 0;
}
} else {
flush();
}
} }
} }
@ -192,27 +241,50 @@ export default function JustifiedGallery({
return `${first}-${last}-${row.length}`; return `${first}-${last}-${row.length}`;
}, []); }, []);
const isSmallScreen = containerWidth < 640;
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={cn("mx-auto w-full max-w-6xl", className)} className={cn("mx-auto w-full max-w-6xl", className)}
> >
<div className="space-y-3"> <div className="space-y-3">
{rows.map((row) => ( {rows.map((row, idx) => (
<div <div key={getRowKey(row)}>
key={getRowKey(row)} <div
className="flex justify-center" className={cn(
style={{ gap }} "flex",
> row.length === 1 && (isSmallScreen || idx !== rows.length - 1)
{row.map((t) => ( ? "justify-center"
<GalleryTile : idx === rows.length - 1
key={t.item.id} ? "justify-start"
tile={t} : "justify-between",
hrefBase={hrefBase} )}
hrefFrom={hrefFrom} style={{ columnGap: gap }}
showCaption={showCaption} >
/> {row.map((t) => (
))} <GalleryTile
key={t.item.id}
tile={t}
hrefBase={hrefBase}
hrefFrom={hrefFrom}
showCaption={showCaption}
/>
))}
</div>
{debug ? (
<div className="text-xs text-muted-foreground font-mono">
{`row ${idx + 1} | h=${Math.round(row[0]?.h ?? 0)} | w=${Math.round(
row.reduce((sum, t) => sum + t.w, 0) + gap * (row.length - 1),
)} | items=${row.length} | `}
{row
.map(
(t) =>
`${t.item.id}:${Math.round(t.w)}x${Math.round(t.h)} (src ${t.item.width}x${t.item.height})`,
)
.join(" | ")}
</div>
) : null}
</div> </div>
))} ))}
</div> </div>

View File

@ -90,17 +90,17 @@ export default function PortfolioGallery({
dominantHex: it.dominantHex, dominantHex: it.dominantHex,
})); }));
useEffect(() => { // useEffect(() => {
if (items.length === 0) return; // if (items.length === 0) return;
// Debug: inspect dominantHex values coming from the server. // // Debug: inspect dominantHex values coming from the server.
console.log( // console.log(
"[PortfolioGallery] dominantHex sample", // "[PortfolioGallery] dominantHex sample",
items.slice(0, 5).map((it) => ({ // items.slice(0, 5).map((it) => ({
id: it.id, // id: it.id,
dominantHex: it.dominantHex, // dominantHex: it.dominantHex,
})) // }))
); // );
}, [items]); // }, [items]);
if (!loading && done && galleryItems.length === 0) { if (!loading && done && galleryItems.length === 0) {
return ( return (
@ -116,6 +116,7 @@ export default function PortfolioGallery({
items={galleryItems} items={galleryItems}
hrefFrom="portfolio" hrefFrom="portfolio"
showCaption={false} showCaption={false}
debug={false}
targetRowHeight={160} targetRowHeight={160}
targetRowHeightMobile={160} targetRowHeightMobile={160}
maxRowHeight={300} maxRowHeight={300}