Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
eb8dcd54a8
|
|||
|
030065631c
|
|||
|
5a3e567ed5
|
|||
|
84dc219a14
|
|||
|
96efd4c942
|
94
src/actions/animalStudies/getAnimalStudiesPage.ts
Normal file
94
src/actions/animalStudies/getAnimalStudiesPage.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import type { JustifiedGalleryItem } from "@/components/gallery/JustifiedGallery";
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type AnimalStudiesCursor = { sortKey: number; id: string } | null;
|
||||||
|
|
||||||
|
export type AnimalStudiesPage = {
|
||||||
|
items: JustifiedGalleryItem[];
|
||||||
|
nextCursor: AnimalStudiesCursor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
take: z.number().int().min(1).max(200).default(60),
|
||||||
|
cursor: z
|
||||||
|
.object({
|
||||||
|
sortKey: z.number().int(),
|
||||||
|
id: z.string().min(1),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
tagSlugs: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getAnimalStudiesPage(input: unknown): Promise<AnimalStudiesPage> {
|
||||||
|
const { take, cursor, tagSlugs } = inputSchema.parse(input);
|
||||||
|
|
||||||
|
const where: Prisma.ArtworkWhereInput = {
|
||||||
|
published: true,
|
||||||
|
// enforce deterministic ordering / pagination
|
||||||
|
sortKey: { not: null },
|
||||||
|
categories: { some: { name: "Animal Studies" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tagSlugs?.length) {
|
||||||
|
where.tags = { some: { slug: { in: tagSlugs } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
where.OR = [
|
||||||
|
{ sortKey: { gt: cursor.sortKey } },
|
||||||
|
{ sortKey: cursor.sortKey, id: { gt: cursor.id } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.artwork.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
altText: true,
|
||||||
|
sortKey: true,
|
||||||
|
file: { select: { fileKey: true } },
|
||||||
|
variants: {
|
||||||
|
where: { type: "resized" },
|
||||||
|
select: { width: true, height: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
metadata: { select: { width: true, height: true } },
|
||||||
|
colors: {
|
||||||
|
select: { color: { select: { hex: true } } },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ sortKey: "asc" }, { id: "asc" }],
|
||||||
|
take: take + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const slice = rows.slice(0, take);
|
||||||
|
const next = rows.length > take ? rows[take] : null;
|
||||||
|
|
||||||
|
const items: JustifiedGalleryItem[] = slice.map((r) => {
|
||||||
|
const v = r.variants[0];
|
||||||
|
const w = v?.width ?? r.metadata?.width ?? 4;
|
||||||
|
const h = v?.height ?? r.metadata?.height ?? 3;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
altText: r.altText,
|
||||||
|
fileKey: r.file.fileKey,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
dominantHex: r.colors?.[0]?.color?.hex ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextCursor: AnimalStudiesCursor =
|
||||||
|
next && next.sortKey != null ? { sortKey: next.sortKey, id: next.id } : null;
|
||||||
|
|
||||||
|
return { items, nextCursor };
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { Prisma } from "@/generated/prisma/browser";
|
import type { Prisma } from "@/generated/prisma/browser";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export type Cursor = {
|
export type Cursor = {
|
||||||
@ -11,7 +11,6 @@ export type Cursor = {
|
|||||||
export type PortfolioArtworkItem = {
|
export type PortfolioArtworkItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
|
||||||
altText: string | null;
|
altText: string | null;
|
||||||
|
|
||||||
sortKey: number | null;
|
sortKey: number | null;
|
||||||
@ -63,7 +62,8 @@ export async function getPortfolioArtworksPage(args: {
|
|||||||
|
|
||||||
const year = coerceYear(filters.year ?? null);
|
const year = coerceYear(filters.year ?? null);
|
||||||
const q = normQ(filters.q);
|
const q = normQ(filters.q);
|
||||||
const albumId = filters.albumId && filters.albumId !== "all" ? filters.albumId : null;
|
const albumId =
|
||||||
|
filters.albumId && filters.albumId !== "all" ? filters.albumId : null;
|
||||||
|
|
||||||
const baseWhere: Prisma.ArtworkWhereInput = {
|
const baseWhere: Prisma.ArtworkWhereInput = {
|
||||||
...(onlyPublished ? { published: true } : {}),
|
...(onlyPublished ? { published: true } : {}),
|
||||||
@ -79,10 +79,9 @@ export async function getPortfolioArtworksPage(args: {
|
|||||||
{
|
{
|
||||||
OR: [
|
OR: [
|
||||||
{ name: { contains: q, mode: "insensitive" } },
|
{ name: { contains: q, mode: "insensitive" } },
|
||||||
{ slug: { contains: q, mode: "insensitive" } },
|
{
|
||||||
{ altText: { contains: q, mode: "insensitive" } },
|
tags: { some: { name: { contains: q, mode: "insensitive" } } },
|
||||||
{ tags: { some: { name: { contains: q, mode: "insensitive" } } } },
|
},
|
||||||
{ albums: { some: { name: { contains: q, mode: "insensitive" } } } },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -130,14 +129,15 @@ export async function getPortfolioArtworksPage(args: {
|
|||||||
},
|
},
|
||||||
} satisfies Prisma.ArtworkSelect;
|
} satisfies Prisma.ArtworkSelect;
|
||||||
|
|
||||||
const mapRow = (r: any): PortfolioArtworkItem | null => {
|
type ArtworkRow = Prisma.ArtworkGetPayload<{ select: typeof select }>;
|
||||||
|
|
||||||
|
const mapRow = (r: ArtworkRow): PortfolioArtworkItem | null => {
|
||||||
const thumb = pickVariant(r.variants, "thumbnail");
|
const thumb = pickVariant(r.variants, "thumbnail");
|
||||||
if (!thumb?.width || !thumb?.height) return null;
|
if (!thumb?.width || !thumb?.height) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
slug: r.slug,
|
|
||||||
altText: r.altText ?? null,
|
altText: r.altText ?? null,
|
||||||
sortKey: r.sortKey ?? null,
|
sortKey: r.sortKey ?? null,
|
||||||
year: r.year ?? null,
|
year: r.year ?? null,
|
||||||
@ -171,20 +171,27 @@ export async function getPortfolioArtworksPage(args: {
|
|||||||
select,
|
select,
|
||||||
});
|
});
|
||||||
|
|
||||||
items = rowsA.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null);
|
items = rowsA
|
||||||
|
.map(mapRow)
|
||||||
|
.filter((x): x is PortfolioArtworkItem => x !== null);
|
||||||
|
|
||||||
if (items.length >= take) {
|
if (items.length >= take) {
|
||||||
const last = items[items.length - 1]!;
|
const last = items.at(-1);
|
||||||
nextCursor = { afterSortKey: last.sortKey!, afterId: last.id };
|
if (!last) {
|
||||||
|
return { items, nextCursor: null, total, years, albums };
|
||||||
|
}
|
||||||
|
// last.sortKey can be null only in null-segment, which we are not in here.
|
||||||
|
if (last.sortKey == null) {
|
||||||
|
return { items, nextCursor: null, total, years, albums };
|
||||||
|
}
|
||||||
|
nextCursor = { afterSortKey: last.sortKey, afterId: last.id };
|
||||||
return { items, nextCursor, total, years, albums };
|
return { items, nextCursor, total, years, albums };
|
||||||
}
|
}
|
||||||
|
|
||||||
const remaining = take - items.length;
|
const remaining = take - items.length;
|
||||||
const lastAId = items.length ? items[items.length - 1]!.id : null;
|
|
||||||
|
|
||||||
const whereB: Prisma.ArtworkWhereInput = {
|
const whereB: Prisma.ArtworkWhereInput = {
|
||||||
AND: [where, { sortKey: null }],
|
AND: [where, { sortKey: null }],
|
||||||
...(lastAId ? { id: { gt: lastAId } } : {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const rowsB = await prisma.artwork.findMany({
|
const rowsB = await prisma.artwork.findMany({
|
||||||
@ -194,7 +201,9 @@ export async function getPortfolioArtworksPage(args: {
|
|||||||
select,
|
select,
|
||||||
});
|
});
|
||||||
|
|
||||||
const more = rowsB.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null);
|
const more = rowsB
|
||||||
|
.map(mapRow)
|
||||||
|
.filter((x): x is PortfolioArtworkItem => x !== null);
|
||||||
items = items.concat(more);
|
items = items.concat(more);
|
||||||
|
|
||||||
const last = items[items.length - 1];
|
const last = items[items.length - 1];
|
||||||
@ -218,11 +227,15 @@ export async function getPortfolioArtworksPage(args: {
|
|||||||
select,
|
select,
|
||||||
});
|
});
|
||||||
|
|
||||||
items = rowsB.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null);
|
items = rowsB
|
||||||
|
.map(mapRow)
|
||||||
|
.filter((x): x is PortfolioArtworkItem => x !== null);
|
||||||
|
|
||||||
const last = items[items.length - 1];
|
const last = items[items.length - 1];
|
||||||
nextCursor =
|
nextCursor =
|
||||||
items.length < take || !last ? null : { afterSortKey: null, afterId: last.id };
|
items.length < take || !last
|
||||||
|
? null
|
||||||
|
: { afterSortKey: null, afterId: last.id };
|
||||||
|
|
||||||
return { items, nextCursor, total, years, albums };
|
return { items, nextCursor, total, years, albums };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,7 +86,7 @@ export default async function AnimalListPage() {
|
|||||||
{list.map((a) => (
|
{list.map((a) => (
|
||||||
<li key={a.id}>
|
<li key={a.id}>
|
||||||
<Link
|
<Link
|
||||||
href={`/artworks/single/${a.id}`}
|
href={`/artworks/single/${a.id}?from=animal-index`}
|
||||||
className="
|
className="
|
||||||
inline-flex items-center gap-2
|
inline-flex items-center gap-2
|
||||||
rounded-md px-2 py-1
|
rounded-md px-2 py-1
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import ArtworkThumbGallery from "@/components/artworks/ArtworkThumbGallery";
|
import AnimalStudiesGallery from "@/components/animalStudies/AnimalStudiesGallery";
|
||||||
import TagFilterDialog from "@/components/artworks/TagFilterDialog";
|
import TagFilterDialog from "@/components/artworks/TagFilterDialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@ -19,7 +19,7 @@ function expandSelectedWithChildren(
|
|||||||
tagsForFilter: Array<{
|
tagsForFilter: Array<{
|
||||||
slug: string;
|
slug: string;
|
||||||
children: Array<{ slug: string }>;
|
children: Array<{ slug: string }>;
|
||||||
}>
|
}>,
|
||||||
) {
|
) {
|
||||||
const bySlug = new Map(tagsForFilter.map((t) => [t.slug, t]));
|
const bySlug = new Map(tagsForFilter.map((t) => [t.slug, t]));
|
||||||
const out = new Set(selectedSlugs);
|
const out = new Set(selectedSlugs);
|
||||||
@ -33,7 +33,11 @@ function expandSelectedWithChildren(
|
|||||||
return Array.from(out);
|
return Array.from(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AnimalStudiesPage({ searchParams }: { searchParams: { tags?: string | string[] } }) {
|
export default async function AnimalStudiesPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: { tags?: string | string[] };
|
||||||
|
}) {
|
||||||
const { tags } = await searchParams;
|
const { tags } = await searchParams;
|
||||||
|
|
||||||
const selectedTagSlugs = parseTagsParam(tags);
|
const selectedTagSlugs = parseTagsParam(tags);
|
||||||
@ -57,28 +61,6 @@ export default async function AnimalStudiesPage({ searchParams }: { searchParams
|
|||||||
|
|
||||||
const expandedTagSlugs = expandSelectedWithChildren(selectedTagSlugs, tagsForFilter);
|
const expandedTagSlugs = expandSelectedWithChildren(selectedTagSlugs, tagsForFilter);
|
||||||
|
|
||||||
const artworks = await prisma.artwork.findMany({
|
|
||||||
where: {
|
|
||||||
categories: { some: { name: "Animal Studies" } },
|
|
||||||
published: true,
|
|
||||||
...(expandedTagSlugs.length
|
|
||||||
? { tags: { some: { slug: { in: expandedTagSlugs } } } }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
file: true,
|
|
||||||
metadata: true,
|
|
||||||
tags: true,
|
|
||||||
variants: true,
|
|
||||||
colors: {
|
|
||||||
select: { color: { select: { hex: true } } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
orderBy: [{ sortKey: "asc" }, { id: "asc" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
// console.log(JSON.stringify(artworks, null, 4))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
||||||
<header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
<header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
@ -88,16 +70,14 @@ export default async function AnimalStudiesPage({ searchParams }: { searchParams
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{selectedTagSlugs.length > 0
|
{selectedTagSlugs.length > 0
|
||||||
? `Filtered by ${selectedTagSlugs.length} tag${selectedTagSlugs.length === 1 ? "" : "s"}`
|
? `Filtered by ${selectedTagSlugs.length} tag${selectedTagSlugs.length === 1 ? "" : "s"
|
||||||
|
}`
|
||||||
: "Browse all published artworks in this category."}
|
: "Browse all published artworks in this category."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
<TagFilterDialog
|
<TagFilterDialog tags={tagsForFilter} selectedTagSlugs={selectedTagSlugs} />
|
||||||
tags={tagsForFilter}
|
|
||||||
selectedTagSlugs={selectedTagSlugs}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button asChild type="button" variant="secondary" className="h-11 gap-2">
|
<Button asChild type="button" variant="secondary" className="h-11 gap-2">
|
||||||
<Link href="/artworks/animalstudies/index">
|
<Link href="/artworks/animalstudies/index">
|
||||||
@ -108,7 +88,7 @@ export default async function AnimalStudiesPage({ searchParams }: { searchParams
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ArtworkThumbGallery items={artworks} fit={{ mode: "fixedWidth", width: 300 }} />
|
<AnimalStudiesGallery key={expandedTagSlugs.join(",")} tagSlugs={expandedTagSlugs} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { PortfolioFilters } from "@/actions/portfolio/getPortfolioArtworksPage";
|
import type { PortfolioFilters } from "@/actions/portfolio/getPortfolioArtworksPage";
|
||||||
import ColorMasonryGallery from "@/components/portfolio/ColorMasonryGallery";
|
|
||||||
import PortfolioFiltersBar from "@/components/portfolio/PortfolioFiltersBar";
|
import PortfolioFiltersBar from "@/components/portfolio/PortfolioFiltersBar";
|
||||||
|
import PortfolioGallery from "@/components/portfolio/PortfolioGallery";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
type SearchParams = {
|
type SearchParams = {
|
||||||
@ -14,11 +14,11 @@ function parseFilters(sp: SearchParams): PortfolioFilters {
|
|||||||
const yearRaw = sp.year?.trim();
|
const yearRaw = sp.year?.trim();
|
||||||
if (yearRaw && yearRaw !== "all") {
|
if (yearRaw && yearRaw !== "all") {
|
||||||
const y = Number(yearRaw);
|
const y = Number(yearRaw);
|
||||||
if (Number.isFinite(y) && y > 0) (filters as any).year = y;
|
if (Number.isFinite(y) && y > 0) filters.year = y;
|
||||||
}
|
}
|
||||||
|
|
||||||
const qRaw = sp.q?.trim();
|
const qRaw = sp.q?.trim();
|
||||||
if (qRaw) (filters as any).q = qRaw;
|
if (qRaw) filters.q = qRaw;
|
||||||
|
|
||||||
return filters;
|
return filters;
|
||||||
}
|
}
|
||||||
@ -53,12 +53,22 @@ export default async function PortfolioPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
<header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<h1 className="text-2xl font-semibold">Portfolio</h1>
|
<div className="space-y-1">
|
||||||
<PortfolioFiltersBar years={years} />
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
|
Portfolio
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Browse all published artworks.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ColorMasonryGallery filters={filters} />
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
|
<PortfolioFiltersBar years={years} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<PortfolioGallery filters={filters} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -173,7 +173,7 @@
|
|||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
/* Inky navy background (clearly not neutral) */
|
/* Inky navy background (clearly not neutral) */
|
||||||
--background: oklch(0.12 0.035 255);
|
--background: oklch(15.774% 0.03835 263.588);
|
||||||
--foreground: oklch(0.95 0.012 85);
|
--foreground: oklch(0.95 0.012 85);
|
||||||
|
|
||||||
/* Surfaces */
|
/* Surfaces */
|
||||||
|
|||||||
@ -19,6 +19,11 @@ const geistMono = Geist_Mono({
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Gaertan Art",
|
title: "Gaertan Art",
|
||||||
description: "Portfolio, Artworks and Commission Requests",
|
description: "Portfolio, Artworks and Commission Requests",
|
||||||
|
alternates: {
|
||||||
|
types: {
|
||||||
|
"application/rss+xml": "/rss.xml",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
71
src/app/rss.xml/route.ts
Normal file
71
src/app/rss.xml/route.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const BASE_URL = `${process.env.FEED_URL}`
|
||||||
|
|
||||||
|
function escapeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const items = await prisma.artwork.findMany({
|
||||||
|
where: { published: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 10,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
altText: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastBuildDate =
|
||||||
|
items[0]?.updatedAt?.toUTCString() ?? new Date().toUTCString();
|
||||||
|
|
||||||
|
const itemXml = items
|
||||||
|
.map((item) => {
|
||||||
|
const title = escapeXml(item.name || "Artwork");
|
||||||
|
const description = escapeXml(
|
||||||
|
item.description || item.altText || item.name || "Artwork"
|
||||||
|
);
|
||||||
|
const link = `${BASE_URL}/artworks/single/${item.id}`;
|
||||||
|
const pubDate = item.createdAt.toUTCString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
"<item>",
|
||||||
|
`<title>${title}</title>`,
|
||||||
|
`<link>${link}</link>`,
|
||||||
|
`<guid isPermaLink="true">${link}</guid>`,
|
||||||
|
`<description>${description}</description>`,
|
||||||
|
`<pubDate>${pubDate}</pubDate>`,
|
||||||
|
"</item>",
|
||||||
|
].join("");
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const xml = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<rss version="2.0">',
|
||||||
|
"<channel>",
|
||||||
|
"<title>Gaertan Art - Latest Artworks</title>",
|
||||||
|
`<link>${BASE_URL}</link>`,
|
||||||
|
"<description>Ten newest artworks from Gaertan Art.</description>",
|
||||||
|
`<lastBuildDate>${lastBuildDate}</lastBuildDate>`,
|
||||||
|
itemXml,
|
||||||
|
"</channel>",
|
||||||
|
"</rss>",
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
return new Response(xml, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/rss+xml; charset=utf-8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -3,12 +3,20 @@ import {
|
|||||||
siLinktree,
|
siLinktree,
|
||||||
siMastodon,
|
siMastodon,
|
||||||
siPaypal,
|
siPaypal,
|
||||||
|
siRss,
|
||||||
siTelegram,
|
siTelegram,
|
||||||
siTwitch,
|
siTwitch,
|
||||||
type SimpleIcon,
|
type SimpleIcon,
|
||||||
} from "simple-icons";
|
} from "simple-icons";
|
||||||
|
|
||||||
type SocialKey = "paypal" | "telegram" | "mastodon" | "bluesky" | "linktree" | "twitch";
|
type SocialKey =
|
||||||
|
| "paypal"
|
||||||
|
| "telegram"
|
||||||
|
| "mastodon"
|
||||||
|
| "bluesky"
|
||||||
|
| "linktree"
|
||||||
|
| "twitch"
|
||||||
|
| "rss";
|
||||||
|
|
||||||
const SOCIALS: Record<
|
const SOCIALS: Record<
|
||||||
SocialKey,
|
SocialKey,
|
||||||
@ -43,7 +51,12 @@ const SOCIALS: Record<
|
|||||||
label: "Twitch",
|
label: "Twitch",
|
||||||
icon: siTwitch,
|
icon: siTwitch,
|
||||||
href: "https://www.twitch.tv/gaertan_art",
|
href: "https://www.twitch.tv/gaertan_art",
|
||||||
}
|
},
|
||||||
|
rss: {
|
||||||
|
label: "RSS",
|
||||||
|
icon: siRss,
|
||||||
|
href: `/rss.xml`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function BrandSvg({ icon }: { icon: SimpleIcon }) {
|
function BrandSvg({ icon }: { icon: SimpleIcon }) {
|
||||||
@ -60,7 +73,15 @@ function BrandSvg({ icon }: { icon: SimpleIcon }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SocialLinks({
|
export function SocialLinks({
|
||||||
items = ["paypal", "telegram", "mastodon", "bluesky", "linktree", "twitch"],
|
items = [
|
||||||
|
"paypal",
|
||||||
|
"telegram",
|
||||||
|
"mastodon",
|
||||||
|
"bluesky",
|
||||||
|
"linktree",
|
||||||
|
"twitch",
|
||||||
|
"rss",
|
||||||
|
],
|
||||||
size = "md",
|
size = "md",
|
||||||
}: {
|
}: {
|
||||||
items?: SocialKey[];
|
items?: SocialKey[];
|
||||||
|
|||||||
76
src/components/animalStudies/AnimalStudiesGallery.tsx
Normal file
76
src/components/animalStudies/AnimalStudiesGallery.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import type { AnimalStudiesCursor } from "@/actions/animalStudies/getAnimalStudiesPage";
|
||||||
|
import { getAnimalStudiesPage } from "@/actions/animalStudies/getAnimalStudiesPage";
|
||||||
|
import JustifiedGallery, { type JustifiedGalleryItem } from "@/components/gallery/JustifiedGallery";
|
||||||
|
|
||||||
|
export default function AnimalStudiesGallery({
|
||||||
|
tagSlugs,
|
||||||
|
}: {
|
||||||
|
tagSlugs: string[];
|
||||||
|
}) {
|
||||||
|
const [items, setItems] = useState<JustifiedGalleryItem[]>([]);
|
||||||
|
const [cursor, setCursor] = useState<AnimalStudiesCursor>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const inFlight = useRef(false);
|
||||||
|
|
||||||
|
// Reset when tag filter changes (component key may already remount, but keep it safe)
|
||||||
|
useEffect(() => {
|
||||||
|
setItems([]);
|
||||||
|
setCursor(null);
|
||||||
|
setDone(false);
|
||||||
|
setLoading(false);
|
||||||
|
inFlight.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (inFlight.current || done) return;
|
||||||
|
inFlight.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getAnimalStudiesPage({
|
||||||
|
take: 60,
|
||||||
|
cursor,
|
||||||
|
tagSlugs,
|
||||||
|
});
|
||||||
|
|
||||||
|
setItems((prev) => {
|
||||||
|
const seen = new Set(prev.map((x) => x.id));
|
||||||
|
const next = res.items.filter((x) => !seen.has(x.id));
|
||||||
|
return prev.concat(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
setCursor(res.nextCursor);
|
||||||
|
if (!res.nextCursor) setDone(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
inFlight.current = false;
|
||||||
|
}
|
||||||
|
}, [cursor, done, tagSlugs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadMore();
|
||||||
|
}, [loadMore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JustifiedGallery
|
||||||
|
items={items}
|
||||||
|
hrefFrom="animal-studies"
|
||||||
|
showCaption
|
||||||
|
targetRowHeight={160}
|
||||||
|
targetRowHeightMobile={160}
|
||||||
|
maxRowHeight={300}
|
||||||
|
maxRowItems={5}
|
||||||
|
maxRowItemsMobile={1}
|
||||||
|
gap={12}
|
||||||
|
onLoadMore={done ? undefined : () => void loadMore()}
|
||||||
|
hasMore={!done}
|
||||||
|
isLoadingMore={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import * as React from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
type Timelapse = {
|
type Timelapse = {
|
||||||
s3Key: string;
|
s3Key: string;
|
||||||
@ -25,7 +25,7 @@ export default function ArtworkTimelapseViewer({
|
|||||||
artworkName?: string | null;
|
artworkName?: string | null;
|
||||||
trigger: React.ReactNode;
|
trigger: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// IMPORTANT:
|
// IMPORTANT:
|
||||||
// This assumes your existing `/api/image/[...key]` can stream arbitrary S3 keys.
|
// 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";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
const FROM_TO_PATH: Record<string, string> = {
|
const FROM_TO_PATH: Record<string, string> = {
|
||||||
portfolio: "/portfolio",
|
portfolio: "/artworks",
|
||||||
"animal-studies": "/animal-studies",
|
"animal-studies": "/artworks/animalstudies",
|
||||||
|
"animal-index": "/artworks/animalstudies/index"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ContextBackButton() {
|
export function ContextBackButton() {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { FilterIcon, XIcon } from "lucide-react";
|
import { FilterIcon, XIcon } from "lucide-react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -17,6 +17,7 @@ import {
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
|
||||||
type Tag = {
|
type Tag = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -52,21 +53,20 @@ export default function TagFilterDialog({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [draft, setDraft] = React.useState<string[]>(() => selectedTagSlugs);
|
const [draft, setDraft] = useState<string[]>(() => selectedTagSlugs);
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
setDraft(selectedTagSlugs);
|
setDraft(selectedTagSlugs);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [selectedTagSlugs]);
|
||||||
}, [selectedTagSlugs.join(",")]);
|
|
||||||
|
|
||||||
const hasDraft = draft.length > 0;
|
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[]
|
// Build children mapping from the flat list: parentId -> Tag[]
|
||||||
const childrenByParentId = React.useMemo(() => {
|
const childrenByParentId = useMemo(() => {
|
||||||
const map = new Map<string, Tag[]>();
|
const map = new Map<string, Tag[]>();
|
||||||
for (const t of tags) {
|
for (const t of tags) {
|
||||||
if (!t.parentId) continue;
|
if (!t.parentId) continue;
|
||||||
@ -81,14 +81,14 @@ export default function TagFilterDialog({
|
|||||||
return map;
|
return map;
|
||||||
}, [tags]);
|
}, [tags]);
|
||||||
|
|
||||||
const rootGroups = React.useMemo(() => {
|
const rootGroups = useMemo(() => {
|
||||||
return tags
|
return tags
|
||||||
.filter((t) => t.parentId === null)
|
.filter((t) => t.parentId === null)
|
||||||
.slice()
|
.slice()
|
||||||
.sort(sortTags);
|
.sort(sortTags);
|
||||||
}, [tags]);
|
}, [tags]);
|
||||||
|
|
||||||
const orphanChildren = React.useMemo(() => {
|
const orphanChildren = useMemo(() => {
|
||||||
return tags
|
return tags
|
||||||
.filter((t) => t.parentId !== null && !byId.has(t.parentId))
|
.filter((t) => t.parentId !== null && !byId.has(t.parentId))
|
||||||
.slice()
|
.slice()
|
||||||
@ -181,7 +181,7 @@ export default function TagFilterDialog({
|
|||||||
return (
|
return (
|
||||||
<div key={p.id} className="rounded-lg border p-4">
|
<div key={p.id} className="rounded-lg border p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<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
|
<Checkbox
|
||||||
checked={parentSelected}
|
checked={parentSelected}
|
||||||
onCheckedChange={(v) => onToggleParent(p, Boolean(v))}
|
onCheckedChange={(v) => onToggleParent(p, Boolean(v))}
|
||||||
@ -192,7 +192,7 @@ export default function TagFilterDialog({
|
|||||||
{children.length ? "Parent tag" : "Tag"}
|
{children.length ? "Parent tag" : "Tag"}
|
||||||
</div> */}
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</Label>
|
||||||
|
|
||||||
<Badge variant={parentSelected ? "default" : "outline"}>
|
<Badge variant={parentSelected ? "default" : "outline"}>
|
||||||
{children.length} sub
|
{children.length} sub
|
||||||
@ -206,7 +206,7 @@ export default function TagFilterDialog({
|
|||||||
const disabled = parentSelected;
|
const disabled = parentSelected;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<Label
|
||||||
key={c.id}
|
key={c.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-md border px-3 py-2",
|
"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))}
|
onCheckedChange={(v) => onToggleChild(c.slug, Boolean(v))}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 truncate text-sm">{c.name}</span>
|
<span className="min-w-0 truncate text-sm">{c.name}</span>
|
||||||
</label>
|
</Label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@ -240,7 +240,7 @@ export default function TagFilterDialog({
|
|||||||
{orphanChildren.map((t) => {
|
{orphanChildren.map((t) => {
|
||||||
const checked = selectedSet.has(t.slug);
|
const checked = selectedSet.has(t.slug);
|
||||||
return (
|
return (
|
||||||
<label
|
<Label
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className="flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2 hover:bg-muted/50"
|
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))}
|
onCheckedChange={(v) => onToggleChild(t.slug, Boolean(v))}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 truncate text-sm">{t.name}</span>
|
<span className="min-w-0 truncate text-sm">{t.name}</span>
|
||||||
</label>
|
</Label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -44,7 +44,7 @@ export function FileDropzone({
|
|||||||
// Allow selecting the same file again later (if user removes and re-adds)
|
// Allow selecting the same file again later (if user removes and re-adds)
|
||||||
if (inputRef.current) inputRef.current.value = "";
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
},
|
},
|
||||||
[append, files, maxFiles, onFilesSelected]
|
[append, files, maxFiles, onFilesSelected],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFiles = React.useCallback(
|
const handleFiles = React.useCallback(
|
||||||
@ -54,7 +54,7 @@ export function FileDropzone({
|
|||||||
if (incoming.length === 0) return;
|
if (incoming.length === 0) return;
|
||||||
mergeFiles(incoming);
|
mergeFiles(incoming);
|
||||||
},
|
},
|
||||||
[mergeFiles]
|
[mergeFiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
@ -75,6 +75,7 @@ export function FileDropzone({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// biome-ignore lint: lint/a11y/useSemanticElements
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@ -87,7 +88,7 @@ export function FileDropzone({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full border-2 border-dashed rounded-md p-6 text-center cursor-pointer transition-colors",
|
"w-full border-2 border-dashed rounded-md p-6 text-center cursor-pointer transition-colors",
|
||||||
isDragging ? "border-primary bg-muted" : "border-muted-foreground/30"
|
isDragging ? "border-primary bg-muted" : "border-muted-foreground/30",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
|||||||
291
src/components/gallery/JustifiedGallery.tsx
Normal file
291
src/components/gallery/JustifiedGallery.tsx
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
type CSSProperties,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export type JustifiedGalleryItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
altText: string | null;
|
||||||
|
fileKey: string;
|
||||||
|
|
||||||
|
/** Intrinsic dimensions of the resized/thumbnail variant */
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
|
||||||
|
/** Optional: dominant color for hover ring. */
|
||||||
|
dominantHex?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: JustifiedGalleryItem[];
|
||||||
|
hrefFrom: string;
|
||||||
|
hrefBase?: string; // default: "/artworks/single"
|
||||||
|
showCaption?: boolean;
|
||||||
|
|
||||||
|
// infinite scroll
|
||||||
|
onLoadMore?: () => void;
|
||||||
|
hasMore?: boolean;
|
||||||
|
isLoadingMore?: boolean;
|
||||||
|
|
||||||
|
// layout tuning
|
||||||
|
targetRowHeight?: number; // desktop
|
||||||
|
targetRowHeightMobile?: number; // <640px
|
||||||
|
maxRowHeight?: number;
|
||||||
|
maxRowItems?: number; // desktop
|
||||||
|
maxRowItemsMobile?: number; // <640px
|
||||||
|
gap?: number; // px
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RowTile = {
|
||||||
|
item: JustifiedGalleryItem;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function aspectOf(it: JustifiedGalleryItem) {
|
||||||
|
const w = Math.max(1, it.width);
|
||||||
|
const h = Math.max(1, it.height);
|
||||||
|
return w / h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeColor(value: string | null | undefined) {
|
||||||
|
if (!value) return null;
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return null;
|
||||||
|
if (v.startsWith("#") || v.startsWith("rgb") || v.startsWith("hsl")) return v;
|
||||||
|
const hex = v.replace(/^0x/i, "");
|
||||||
|
if (/^[0-9a-fA-F]{3}$/.test(hex) || /^[0-9a-fA-F]{6}$/.test(hex)) {
|
||||||
|
return `#${hex}`;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
export default function JustifiedGallery({
|
||||||
|
items,
|
||||||
|
hrefFrom,
|
||||||
|
hrefBase = "/artworks/single",
|
||||||
|
showCaption = false,
|
||||||
|
onLoadMore,
|
||||||
|
hasMore = false,
|
||||||
|
isLoadingMore = false,
|
||||||
|
targetRowHeight = 220,
|
||||||
|
targetRowHeightMobile = 160,
|
||||||
|
maxRowHeight = 260,
|
||||||
|
maxRowItems = 5,
|
||||||
|
maxRowItemsMobile = 3,
|
||||||
|
gap = 12,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
|
|
||||||
|
// Measure container width (responsive)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => setContainerWidth(el.clientWidth));
|
||||||
|
ro.observe(el);
|
||||||
|
setContainerWidth(el.clientWidth);
|
||||||
|
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Infinite scroll sentinel
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onLoadMore || !hasMore) return;
|
||||||
|
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting && !isLoadingMore) onLoadMore();
|
||||||
|
},
|
||||||
|
{ rootMargin: "900px 0px" },
|
||||||
|
);
|
||||||
|
|
||||||
|
io.observe(el);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [onLoadMore, hasMore, isLoadingMore]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
if (!containerWidth) return [] as RowTile[][];
|
||||||
|
|
||||||
|
const isMobile = containerWidth < 640;
|
||||||
|
const targetH = isMobile ? targetRowHeightMobile : targetRowHeight;
|
||||||
|
const maxItems = isMobile ? maxRowItemsMobile : maxRowItems;
|
||||||
|
|
||||||
|
const rowTiles: RowTile[][] = [];
|
||||||
|
let current: Array<{ item: JustifiedGalleryItem; aspect: number }> = [];
|
||||||
|
let aspectSum = 0;
|
||||||
|
|
||||||
|
const available = containerWidth;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (current.length === 0) return;
|
||||||
|
|
||||||
|
const gaps = gap * (current.length - 1);
|
||||||
|
const widthWithoutGaps = Math.max(0, available - gaps);
|
||||||
|
|
||||||
|
// Compute row height so it exactly fills the row width.
|
||||||
|
const computedH = widthWithoutGaps / aspectSum;
|
||||||
|
const h = Math.min(computedH, maxRowHeight);
|
||||||
|
|
||||||
|
rowTiles.push(
|
||||||
|
current.map((x) => ({
|
||||||
|
item: x.item,
|
||||||
|
h,
|
||||||
|
w: Math.round(x.aspect * h),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
current = [];
|
||||||
|
aspectSum = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const it of items) {
|
||||||
|
const a = aspectOf(it);
|
||||||
|
|
||||||
|
current.push({ item: it, aspect: a });
|
||||||
|
aspectSum += a;
|
||||||
|
|
||||||
|
// Estimate the row width if we were to keep targetH
|
||||||
|
const estimatedWidth = aspectSum * targetH + gap * (current.length - 1);
|
||||||
|
|
||||||
|
// If we've filled the row (or reached max items) and have at least 2 tiles, flush.
|
||||||
|
if (
|
||||||
|
(estimatedWidth >= available || current.length >= maxItems) &&
|
||||||
|
current.length > 1
|
||||||
|
) {
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flush();
|
||||||
|
return rowTiles;
|
||||||
|
}, [
|
||||||
|
items,
|
||||||
|
containerWidth,
|
||||||
|
gap,
|
||||||
|
targetRowHeight,
|
||||||
|
targetRowHeightMobile,
|
||||||
|
maxRowHeight,
|
||||||
|
maxRowItems,
|
||||||
|
maxRowItemsMobile,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getRowKey = useCallback((row: RowTile[]) => {
|
||||||
|
const first = row[0]?.item.id ?? "row";
|
||||||
|
const last = row.at(-1)?.item.id ?? "row";
|
||||||
|
return `${first}-${last}-${row.length}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn("mx-auto w-full max-w-6xl", className)}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={getRowKey(row)}
|
||||||
|
className="flex justify-center"
|
||||||
|
style={{ gap }}
|
||||||
|
>
|
||||||
|
{row.map((t) => (
|
||||||
|
<GalleryTile
|
||||||
|
key={t.item.id}
|
||||||
|
tile={t}
|
||||||
|
hrefBase={hrefBase}
|
||||||
|
hrefFrom={hrefFrom}
|
||||||
|
showCaption={showCaption}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onLoadMore ? <div ref={sentinelRef} className="h-px w-full" /> : null}
|
||||||
|
{isLoadingMore ? (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GalleryTile({
|
||||||
|
tile,
|
||||||
|
hrefBase,
|
||||||
|
hrefFrom,
|
||||||
|
showCaption,
|
||||||
|
}: {
|
||||||
|
tile: RowTile;
|
||||||
|
hrefBase: string;
|
||||||
|
hrefFrom: string;
|
||||||
|
showCaption: boolean;
|
||||||
|
}) {
|
||||||
|
const { item, w, h } = tile;
|
||||||
|
|
||||||
|
const href = `${hrefBase}/${item.id}?from=${encodeURIComponent(hrefFrom)}`;
|
||||||
|
const src = `/api/image/gallery/${item.fileKey}.webp`;
|
||||||
|
|
||||||
|
const style: CSSProperties & { "--dom"?: string } = {};
|
||||||
|
const dom = normalizeColor(item.dominantHex);
|
||||||
|
if (dom) style["--dom"] = dom;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
style={{ width: w, height: h, ...style }}
|
||||||
|
className={cn(
|
||||||
|
"group relative overflow-hidden rounded-lg border bg-background",
|
||||||
|
"transition-shadow hover:shadow-lg",
|
||||||
|
// keep border visible even if theme border is subtle
|
||||||
|
"border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Solid vibrant hover ring (no gradient), driven by --dom.
|
||||||
|
Using box-shadow is more reliable than border-color overrides. */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 pointer-events-none rounded-lg transition-[box-shadow,opacity] duration-150",
|
||||||
|
// default no ring
|
||||||
|
"shadow-none opacity-0",
|
||||||
|
// on hover show ring
|
||||||
|
"group-hover:shadow-[inset_0_0_0_2px_var(--dom)]",
|
||||||
|
"group-hover:opacity-100",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={item.altText ?? item.name ?? "Artwork"}
|
||||||
|
width={w}
|
||||||
|
height={h}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
// Tiles are thumbnail-ish; bias Next toward small resources.
|
||||||
|
sizes="(max-width: 640px) 90vw, (max-width: 1024px) 50vw, 320px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showCaption ? (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 bg-black/60 p-3">
|
||||||
|
<div className="text-sm font-medium text-white line-clamp-1">
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,224 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
Cursor,
|
|
||||||
PortfolioArtworkItem,
|
|
||||||
PortfolioFilters,
|
|
||||||
} from "@/actions/portfolio/getPortfolioArtworksPage";
|
|
||||||
import { getPortfolioArtworksPage } from "@/actions/portfolio/getPortfolioArtworksPage";
|
|
||||||
import { ArtworkImageCard } from "../artworks/ArtworkImageCard";
|
|
||||||
|
|
||||||
type Placement = {
|
|
||||||
id: string;
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
w: number;
|
|
||||||
h: number;
|
|
||||||
dominantHex: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function computeCols(
|
|
||||||
containerW: number,
|
|
||||||
gap: number,
|
|
||||||
minColW: number,
|
|
||||||
maxCols: number
|
|
||||||
) {
|
|
||||||
const cols = Math.max(
|
|
||||||
1,
|
|
||||||
Math.min(maxCols, Math.floor((containerW + gap) / (minColW + gap)))
|
|
||||||
);
|
|
||||||
const colW = Math.floor((containerW - gap * (cols - 1)) / cols);
|
|
||||||
return { cols, colW: Math.max(1, colW) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function packStableMasonry(
|
|
||||||
items: PortfolioArtworkItem[],
|
|
||||||
containerW: number,
|
|
||||||
opts: { gap: number; minColW: number; maxCols: number }
|
|
||||||
): { placements: Placement[]; height: number } {
|
|
||||||
const { gap, minColW, maxCols } = opts;
|
|
||||||
if (containerW <= 0 || items.length === 0) return { placements: [], height: 0 };
|
|
||||||
|
|
||||||
const { cols, colW } = computeCols(containerW, gap, minColW, maxCols);
|
|
||||||
const colHeights = Array(cols).fill(0) as number[];
|
|
||||||
const placements: Placement[] = [];
|
|
||||||
|
|
||||||
for (const it of items) {
|
|
||||||
let cBest = 0;
|
|
||||||
for (let c = 1; c < cols; c++) if (colHeights[c] < colHeights[cBest]) cBest = c;
|
|
||||||
|
|
||||||
const ratio = it.thumbH / it.thumbW;
|
|
||||||
const h = Math.round(colW * ratio);
|
|
||||||
|
|
||||||
const top = colHeights[cBest];
|
|
||||||
const left = cBest * (colW + gap);
|
|
||||||
|
|
||||||
placements.push({
|
|
||||||
id: it.id,
|
|
||||||
top,
|
|
||||||
left,
|
|
||||||
w: colW,
|
|
||||||
h,
|
|
||||||
dominantHex: it.dominantHex,
|
|
||||||
});
|
|
||||||
|
|
||||||
colHeights[cBest] = top + h + gap;
|
|
||||||
}
|
|
||||||
|
|
||||||
const height = Math.max(...colHeights) - gap;
|
|
||||||
return { placements, height: Math.max(0, height) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function thumbUrl(fileKey: string) {
|
|
||||||
return `/api/image/resized/${fileKey}.webp`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useResizeObserverWidth() {
|
|
||||||
const ref = React.useRef<HTMLDivElement>(null);
|
|
||||||
const [w, setW] = React.useState(0);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
const ro = new ResizeObserver(([e]) => setW(Math.floor(e.contentRect.width)));
|
|
||||||
ro.observe(el);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { ref, w };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ColorMasonryGallery({
|
|
||||||
filters,
|
|
||||||
}: {
|
|
||||||
filters: PortfolioFilters;
|
|
||||||
}) {
|
|
||||||
const { ref: containerRef, w: containerW } = useResizeObserverWidth();
|
|
||||||
|
|
||||||
const [items, setItems] = React.useState<PortfolioArtworkItem[]>([]);
|
|
||||||
const [done, setDone] = React.useState(false);
|
|
||||||
const [loading, setLoading] = React.useState(false);
|
|
||||||
|
|
||||||
const inFlight = React.useRef(false);
|
|
||||||
const doneRef = React.useRef(false);
|
|
||||||
doneRef.current = done;
|
|
||||||
|
|
||||||
const cursorRef = React.useRef<Cursor>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setItems([]);
|
|
||||||
setDone(false);
|
|
||||||
doneRef.current = false;
|
|
||||||
inFlight.current = false;
|
|
||||||
cursorRef.current = null;
|
|
||||||
}, [filters]);
|
|
||||||
|
|
||||||
const loadMore = React.useCallback(async () => {
|
|
||||||
if (inFlight.current || doneRef.current) return 0;
|
|
||||||
inFlight.current = true;
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await getPortfolioArtworksPage({
|
|
||||||
take: 60,
|
|
||||||
cursor: cursorRef.current,
|
|
||||||
filters,
|
|
||||||
onlyPublished: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Defensive dedupe: prevents accidental repeats from any future cursor edge case
|
|
||||||
setItems((prev) => {
|
|
||||||
const seen = new Set(prev.map((x) => x.id));
|
|
||||||
const next = data.items.filter((x) => !seen.has(x.id));
|
|
||||||
return prev.concat(next);
|
|
||||||
});
|
|
||||||
|
|
||||||
cursorRef.current = data.nextCursor;
|
|
||||||
if (!data.nextCursor) setDone(true);
|
|
||||||
|
|
||||||
return data.items.length;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
inFlight.current = false;
|
|
||||||
}
|
|
||||||
}, [filters]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
void loadMore();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [loadMore]);
|
|
||||||
|
|
||||||
const sentinelRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
React.useEffect(() => {
|
|
||||||
const sentinel = sentinelRef.current;
|
|
||||||
if (!sentinel) return;
|
|
||||||
|
|
||||||
const io = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (entries.some((e) => e.isIntersecting)) void loadMore();
|
|
||||||
},
|
|
||||||
{ rootMargin: "900px 0px", threshold: 0.01 }
|
|
||||||
);
|
|
||||||
|
|
||||||
io.observe(sentinel);
|
|
||||||
return () => io.disconnect();
|
|
||||||
}, [loadMore]);
|
|
||||||
|
|
||||||
const GAP = 14;
|
|
||||||
const MIN_COL_W = 260;
|
|
||||||
const MAX_COLS = 6;
|
|
||||||
|
|
||||||
const { placements, height } = React.useMemo(() => {
|
|
||||||
return packStableMasonry(items, containerW, {
|
|
||||||
gap: GAP,
|
|
||||||
minColW: MIN_COL_W,
|
|
||||||
maxCols: MAX_COLS,
|
|
||||||
});
|
|
||||||
}, [items, containerW]);
|
|
||||||
|
|
||||||
const itemsById = React.useMemo(() => new Map(items.map((it) => [it.id, it])), [items]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="w-full">
|
|
||||||
<div className="relative w-full" style={{ height }}>
|
|
||||||
{placements.map((p) => {
|
|
||||||
const it = itemsById.get(p.id);
|
|
||||||
if (!it) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={p.id}
|
|
||||||
className="absolute"
|
|
||||||
style={{
|
|
||||||
transform: `translate(${p.left}px, ${p.top}px)`,
|
|
||||||
width: p.w,
|
|
||||||
height: p.h,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* <div style={{ ["--dom" as any]: p.dominantHex }} className="h-full w-full"> */}
|
|
||||||
<ArtworkImageCard
|
|
||||||
mode="tile"
|
|
||||||
href={`/artworks/single/${it.id}?from=portfolio`}
|
|
||||||
src={thumbUrl(it.fileKey)}
|
|
||||||
alt={it.altText ?? it.name}
|
|
||||||
width={Math.max(1, it.thumbW)}
|
|
||||||
height={Math.max(1, it.thumbH)}
|
|
||||||
style={{ ["--dom" as any]: p.dominantHex }}
|
|
||||||
className="w-full h-full rounded-md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
// </div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!done && <div ref={sentinelRef} style={{ height: 1 }} />}
|
|
||||||
{loading && <p className="text-sm text-muted-foreground mt-3">Loading…</p>}
|
|
||||||
{!loading && done && items.length === 0 && (
|
|
||||||
<p className="text-muted-foreground text-center py-20">No artworks to display</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,7 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { FilterIcon, XIcon } from "lucide-react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import * as React from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
function setParam(params: URLSearchParams, key: string, value?: string | null) {
|
function setParam(params: URLSearchParams, key: string, value?: string | null) {
|
||||||
if (!value) params.delete(key);
|
if (!value) params.delete(key);
|
||||||
@ -16,118 +31,119 @@ export default function PortfolioFiltersBar({ years = [] }: { years?: number[] }
|
|||||||
const yearParam = sp.get("year") ?? "all";
|
const yearParam = sp.get("year") ?? "all";
|
||||||
const qParam = sp.get("q") ?? "";
|
const qParam = sp.get("q") ?? "";
|
||||||
|
|
||||||
// Local input state (typing does NOT change URL)
|
const [open, setOpen] = useState(false);
|
||||||
const [q, setQ] = React.useState(qParam);
|
const [draftYear, setDraftYear] = useState<string>(yearParam);
|
||||||
|
const [draftQ, setDraftQ] = useState<string>(qParam);
|
||||||
|
|
||||||
// Sync input when navigating back/forward (URL -> input)
|
useEffect(() => {
|
||||||
React.useEffect(() => {
|
setDraftYear(yearParam);
|
||||||
setQ(qParam);
|
setDraftQ(qParam);
|
||||||
}, [qParam]);
|
}, [yearParam, qParam]);
|
||||||
|
|
||||||
const pushParams = React.useCallback(
|
const activeCount = (yearParam !== "all" ? 1 : 0) + (qParam.trim().length ? 1 : 0);
|
||||||
(mutate: (next: URLSearchParams) => void) => {
|
|
||||||
|
const clearAll = () => {
|
||||||
|
setDraftYear("all");
|
||||||
|
setDraftQ("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
const next = new URLSearchParams(sp.toString());
|
const next = new URLSearchParams(sp.toString());
|
||||||
mutate(next);
|
|
||||||
|
|
||||||
const nextQs = next.toString();
|
const year = draftYear.trim();
|
||||||
const currQs = sp.toString();
|
if (!year || year === "all") next.delete("year");
|
||||||
if (nextQs === currQs) return; // guard against redundant replaces
|
else setParam(next, "year", year);
|
||||||
|
|
||||||
router.replace(nextQs ? `${pathname}?${nextQs}` : pathname, { scroll: false });
|
const q = draftQ.trim();
|
||||||
},
|
if (!q) next.delete("q");
|
||||||
[pathname, router, sp]
|
else setParam(next, "q", q);
|
||||||
);
|
|
||||||
|
|
||||||
const setYear = (year: "all" | number) => {
|
const qs = next.toString();
|
||||||
pushParams((next) => {
|
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
||||||
setParam(next, "year", year === "all" ? null : String(year));
|
setOpen(false);
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitSearch = (value: string) => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
pushParams((next) => {
|
|
||||||
setParam(next, "q", trimmed.length ? trimmed : null);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const clear = () => {
|
|
||||||
setQ("");
|
|
||||||
pushParams((next) => {
|
|
||||||
next.delete("year");
|
|
||||||
next.delete("q");
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 flex flex-col gap-3">
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<div className="flex flex-col gap-1">
|
<DialogTrigger asChild>
|
||||||
<div className="text-sm text-muted-foreground">Year</div>
|
<Button type="button" variant="default" className="h-11 gap-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
<FilterIcon className="h-4 w-4" />
|
||||||
<button
|
Filter
|
||||||
type="button"
|
{activeCount > 0 ? (
|
||||||
onClick={() => setYear("all")}
|
<Badge variant="secondary" className="ml-1">
|
||||||
className={[
|
{activeCount}
|
||||||
"h-9 rounded-md border px-3 text-sm",
|
</Badge>
|
||||||
yearParam === "all" ? "bg-accent" : "hover:bg-accent/60",
|
) : null}
|
||||||
].join(" ")}
|
</Button>
|
||||||
>
|
</DialogTrigger>
|
||||||
All
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{years.map((y) => {
|
<DialogContent className="p-0 sm:max-w-xl">
|
||||||
const active = yearParam === String(y);
|
<DialogHeader className="px-6 pt-6">
|
||||||
return (
|
<DialogTitle className="flex items-center justify-between gap-3">
|
||||||
<button
|
<span>Filter portfolio</span>
|
||||||
key={y}
|
{draftYear !== "all" || draftQ.trim().length ? (
|
||||||
type="button"
|
<Button
|
||||||
onClick={() => setYear(y)}
|
variant="ghost"
|
||||||
className={[
|
size="sm"
|
||||||
"h-9 rounded-md border px-3 text-sm",
|
onClick={clearAll}
|
||||||
active ? "bg-accent" : "hover:bg-accent/60",
|
className="gap-2"
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{y}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
className="flex flex-col gap-1 sm:max-w-xl"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
submitSearch(q);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-sm text-muted-foreground">Search (by name or tags)</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
|
||||||
value={q}
|
|
||||||
onChange={(e) => setQ(e.target.value)}
|
|
||||||
placeholder="e.g. lizard, monk, fantasy"
|
|
||||||
inputMode="search"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="h-10 rounded-md border px-3 text-sm hover:bg-accent"
|
|
||||||
>
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="h-10 rounded-md border px-3 text-sm hover:bg-accent"
|
|
||||||
onClick={clear}
|
|
||||||
>
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Filter by year and search by artwork name or tags.
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<ScrollArea className="max-h-[60vh] px-6 py-4">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="portfolio-year">Year</Label>
|
||||||
|
<select
|
||||||
|
id="portfolio-year"
|
||||||
|
value={draftYear}
|
||||||
|
onChange={(e) => setDraftYear(e.target.value)}
|
||||||
|
className="h-11 w-full rounded-md border bg-background px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All years</option>
|
||||||
|
{years.map((y) => (
|
||||||
|
<option key={y} value={String(y)}>
|
||||||
|
{y}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="portfolio-q">Search</Label>
|
||||||
|
<Input
|
||||||
|
id="portfolio-q"
|
||||||
|
value={draftQ}
|
||||||
|
onChange={(e) => setDraftQ(e.target.value)}
|
||||||
|
placeholder="Search name or tags"
|
||||||
|
inputMode="search"
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 px-6 py-4">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={apply}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
131
src/components/portfolio/PortfolioGallery.tsx
Normal file
131
src/components/portfolio/PortfolioGallery.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Cursor,
|
||||||
|
PortfolioArtworkItem,
|
||||||
|
PortfolioFilters,
|
||||||
|
} from "@/actions/portfolio/getPortfolioArtworksPage";
|
||||||
|
import { getPortfolioArtworksPage } from "@/actions/portfolio/getPortfolioArtworksPage";
|
||||||
|
import JustifiedGallery, {
|
||||||
|
type JustifiedGalleryItem,
|
||||||
|
} from "@/components/gallery/JustifiedGallery";
|
||||||
|
|
||||||
|
export default function PortfolioGallery({
|
||||||
|
filters,
|
||||||
|
}: {
|
||||||
|
filters: PortfolioFilters;
|
||||||
|
}) {
|
||||||
|
const { year, albumId, q } = filters;
|
||||||
|
|
||||||
|
const queryFilters = useMemo<PortfolioFilters>(
|
||||||
|
() => ({ year, albumId, q }),
|
||||||
|
[year, albumId, q]
|
||||||
|
);
|
||||||
|
const resetKey = useMemo(
|
||||||
|
() => `${year ?? ""}|${albumId ?? ""}|${q ?? ""}`,
|
||||||
|
[year, albumId, q]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [items, setItems] = useState<PortfolioArtworkItem[]>([]);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const inFlight = useRef(false);
|
||||||
|
const doneRef = useRef(false);
|
||||||
|
doneRef.current = done;
|
||||||
|
const cursorRef = useRef<Cursor>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resetKey == null) return;
|
||||||
|
setItems([]);
|
||||||
|
setDone(false);
|
||||||
|
doneRef.current = false;
|
||||||
|
inFlight.current = false;
|
||||||
|
cursorRef.current = null;
|
||||||
|
}, [resetKey]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (inFlight.current || doneRef.current) return 0;
|
||||||
|
inFlight.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getPortfolioArtworksPage({
|
||||||
|
take: 60,
|
||||||
|
cursor: cursorRef.current,
|
||||||
|
filters: queryFilters,
|
||||||
|
onlyPublished: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Defensive dedupe
|
||||||
|
setItems((prev) => {
|
||||||
|
const seen = new Set(prev.map((x) => x.id));
|
||||||
|
const next = data.items.filter((x) => !seen.has(x.id));
|
||||||
|
return prev.concat(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
cursorRef.current = data.nextCursor;
|
||||||
|
if (!data.nextCursor) setDone(true);
|
||||||
|
|
||||||
|
return data.items.length;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
inFlight.current = false;
|
||||||
|
}
|
||||||
|
}, [queryFilters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadMore();
|
||||||
|
}, [loadMore]);
|
||||||
|
|
||||||
|
const galleryItems: JustifiedGalleryItem[] = items.map((it) => ({
|
||||||
|
id: it.id,
|
||||||
|
name: it.name,
|
||||||
|
altText: it.altText,
|
||||||
|
fileKey: it.fileKey,
|
||||||
|
width: it.thumbW,
|
||||||
|
height: it.thumbH,
|
||||||
|
dominantHex: it.dominantHex,
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
// Debug: inspect dominantHex values coming from the server.
|
||||||
|
console.log(
|
||||||
|
"[PortfolioGallery] dominantHex sample",
|
||||||
|
items.slice(0, 5).map((it) => ({
|
||||||
|
id: it.id,
|
||||||
|
dominantHex: it.dominantHex,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
if (!loading && done && galleryItems.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground text-center py-20">
|
||||||
|
No artworks to display
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<JustifiedGallery
|
||||||
|
items={galleryItems}
|
||||||
|
hrefFrom="portfolio"
|
||||||
|
showCaption={false}
|
||||||
|
targetRowHeight={160}
|
||||||
|
targetRowHeightMobile={160}
|
||||||
|
maxRowHeight={300}
|
||||||
|
maxRowItems={5}
|
||||||
|
maxRowItemsMobile={1}
|
||||||
|
gap={12}
|
||||||
|
onLoadMore={done ? undefined : () => void loadMore()}
|
||||||
|
hasMore={!done}
|
||||||
|
isLoadingMore={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ export const commissionOrderSchema = z.object({
|
|||||||
typeId: z.string().min(1, "Please select a type"),
|
typeId: z.string().min(1, "Please select a type"),
|
||||||
optionId: z.string().min(1, "Please choose a base option"),
|
optionId: z.string().min(1, "Please choose a base option"),
|
||||||
extraIds: z.array(z.string()).optional(),
|
extraIds: z.array(z.string()).optional(),
|
||||||
customFields: z.record(z.string(), z.any()).optional(),
|
customFields: z.record(z.string(), z.unknown()).optional(),
|
||||||
customerName: z.string().min(2, "Enter your name"),
|
customerName: z.string().min(2, "Enter your name"),
|
||||||
customerEmail: z.email("Invalid email"),
|
customerEmail: z.email("Invalid email"),
|
||||||
customerSocials: z.string().optional(),
|
customerSocials: z.string().optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user