Unify portfolio and animal studies galleries
This commit is contained in:
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,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
type Timelapse = {
|
||||
s3Key: string;
|
||||
@ -25,7 +25,7 @@ export default function ArtworkTimelapseViewer({
|
||||
artworkName?: string | null;
|
||||
trigger: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// IMPORTANT:
|
||||
// This assumes your existing `/api/image/[...key]` can stream arbitrary S3 keys.
|
||||
|
||||
@ -5,8 +5,9 @@ import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
const FROM_TO_PATH: Record<string, string> = {
|
||||
portfolio: "/portfolio",
|
||||
"animal-studies": "/animal-studies",
|
||||
portfolio: "/artworks",
|
||||
"animal-studies": "/artworks/animalstudies",
|
||||
"animal-index": "/artworks/animalstudies/index"
|
||||
};
|
||||
|
||||
export function ContextBackButton() {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { FilterIcon, XIcon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -17,6 +17,7 @@ import {
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "../ui/label";
|
||||
|
||||
type Tag = {
|
||||
id: string;
|
||||
@ -52,21 +53,20 @@ export default function TagFilterDialog({
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [draft, setDraft] = React.useState<string[]>(() => selectedTagSlugs);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string[]>(() => selectedTagSlugs);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
setDraft(selectedTagSlugs);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTagSlugs.join(",")]);
|
||||
}, [selectedTagSlugs]);
|
||||
|
||||
const hasDraft = draft.length > 0;
|
||||
const selectedSet = React.useMemo(() => new Set(draft), [draft]);
|
||||
const selectedSet = useMemo(() => new Set(draft), [draft]);
|
||||
|
||||
const byId = React.useMemo(() => new Map(tags.map((t) => [t.id, t])), [tags]);
|
||||
const byId = useMemo(() => new Map(tags.map((t) => [t.id, t])), [tags]);
|
||||
|
||||
// Build children mapping from the flat list: parentId -> Tag[]
|
||||
const childrenByParentId = React.useMemo(() => {
|
||||
const childrenByParentId = useMemo(() => {
|
||||
const map = new Map<string, Tag[]>();
|
||||
for (const t of tags) {
|
||||
if (!t.parentId) continue;
|
||||
@ -81,14 +81,14 @@ export default function TagFilterDialog({
|
||||
return map;
|
||||
}, [tags]);
|
||||
|
||||
const rootGroups = React.useMemo(() => {
|
||||
const rootGroups = useMemo(() => {
|
||||
return tags
|
||||
.filter((t) => t.parentId === null)
|
||||
.slice()
|
||||
.sort(sortTags);
|
||||
}, [tags]);
|
||||
|
||||
const orphanChildren = React.useMemo(() => {
|
||||
const orphanChildren = useMemo(() => {
|
||||
return tags
|
||||
.filter((t) => t.parentId !== null && !byId.has(t.parentId))
|
||||
.slice()
|
||||
@ -181,7 +181,7 @@ export default function TagFilterDialog({
|
||||
return (
|
||||
<div key={p.id} className="rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<Label className="flex cursor-pointer items-center gap-3">
|
||||
<Checkbox
|
||||
checked={parentSelected}
|
||||
onCheckedChange={(v) => onToggleParent(p, Boolean(v))}
|
||||
@ -192,7 +192,7 @@ export default function TagFilterDialog({
|
||||
{children.length ? "Parent tag" : "Tag"}
|
||||
</div> */}
|
||||
</div>
|
||||
</label>
|
||||
</Label>
|
||||
|
||||
<Badge variant={parentSelected ? "default" : "outline"}>
|
||||
{children.length} sub
|
||||
@ -206,7 +206,7 @@ export default function TagFilterDialog({
|
||||
const disabled = parentSelected;
|
||||
|
||||
return (
|
||||
<label
|
||||
<Label
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md border px-3 py-2",
|
||||
@ -220,7 +220,7 @@ export default function TagFilterDialog({
|
||||
onCheckedChange={(v) => onToggleChild(c.slug, Boolean(v))}
|
||||
/>
|
||||
<span className="min-w-0 truncate text-sm">{c.name}</span>
|
||||
</label>
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@ -240,7 +240,7 @@ export default function TagFilterDialog({
|
||||
{orphanChildren.map((t) => {
|
||||
const checked = selectedSet.has(t.slug);
|
||||
return (
|
||||
<label
|
||||
<Label
|
||||
key={t.id}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2 hover:bg-muted/50"
|
||||
>
|
||||
@ -249,7 +249,7 @@ export default function TagFilterDialog({
|
||||
onCheckedChange={(v) => onToggleChild(t.slug, Boolean(v))}
|
||||
/>
|
||||
<span className="min-w-0 truncate text-sm">{t.name}</span>
|
||||
</label>
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -44,7 +44,7 @@ export function FileDropzone({
|
||||
// Allow selecting the same file again later (if user removes and re-adds)
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
},
|
||||
[append, files, maxFiles, onFilesSelected]
|
||||
[append, files, maxFiles, onFilesSelected],
|
||||
);
|
||||
|
||||
const handleFiles = React.useCallback(
|
||||
@ -54,7 +54,7 @@ export function FileDropzone({
|
||||
if (incoming.length === 0) return;
|
||||
mergeFiles(incoming);
|
||||
},
|
||||
[mergeFiles]
|
||||
[mergeFiles],
|
||||
);
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
@ -75,6 +75,7 @@ export function FileDropzone({
|
||||
};
|
||||
|
||||
return (
|
||||
// biome-ignore lint: lint/a11y/useSemanticElements
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@ -87,7 +88,7 @@ export function FileDropzone({
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
"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
|
||||
|
||||
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/thumbnail/${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";
|
||||
|
||||
import { FilterIcon, XIcon } from "lucide-react";
|
||||
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) {
|
||||
if (!value) params.delete(key);
|
||||
@ -16,118 +31,119 @@ export default function PortfolioFiltersBar({ years = [] }: { years?: number[] }
|
||||
const yearParam = sp.get("year") ?? "all";
|
||||
const qParam = sp.get("q") ?? "";
|
||||
|
||||
// Local input state (typing does NOT change URL)
|
||||
const [q, setQ] = React.useState(qParam);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draftYear, setDraftYear] = useState<string>(yearParam);
|
||||
const [draftQ, setDraftQ] = useState<string>(qParam);
|
||||
|
||||
// Sync input when navigating back/forward (URL -> input)
|
||||
React.useEffect(() => {
|
||||
setQ(qParam);
|
||||
}, [qParam]);
|
||||
useEffect(() => {
|
||||
setDraftYear(yearParam);
|
||||
setDraftQ(qParam);
|
||||
}, [yearParam, qParam]);
|
||||
|
||||
const pushParams = React.useCallback(
|
||||
(mutate: (next: URLSearchParams) => void) => {
|
||||
const next = new URLSearchParams(sp.toString());
|
||||
mutate(next);
|
||||
const activeCount = (yearParam !== "all" ? 1 : 0) + (qParam.trim().length ? 1 : 0);
|
||||
|
||||
const nextQs = next.toString();
|
||||
const currQs = sp.toString();
|
||||
if (nextQs === currQs) return; // guard against redundant replaces
|
||||
|
||||
router.replace(nextQs ? `${pathname}?${nextQs}` : pathname, { scroll: false });
|
||||
},
|
||||
[pathname, router, sp]
|
||||
);
|
||||
|
||||
const setYear = (year: "all" | number) => {
|
||||
pushParams((next) => {
|
||||
setParam(next, "year", year === "all" ? null : String(year));
|
||||
});
|
||||
const clearAll = () => {
|
||||
setDraftYear("all");
|
||||
setDraftQ("");
|
||||
};
|
||||
|
||||
const submitSearch = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
pushParams((next) => {
|
||||
setParam(next, "q", trimmed.length ? trimmed : null);
|
||||
});
|
||||
};
|
||||
const apply = () => {
|
||||
const next = new URLSearchParams(sp.toString());
|
||||
|
||||
const clear = () => {
|
||||
setQ("");
|
||||
pushParams((next) => {
|
||||
next.delete("year");
|
||||
next.delete("q");
|
||||
});
|
||||
const year = draftYear.trim();
|
||||
if (!year || year === "all") next.delete("year");
|
||||
else setParam(next, "year", year);
|
||||
|
||||
const q = draftQ.trim();
|
||||
if (!q) next.delete("q");
|
||||
else setParam(next, "q", q);
|
||||
|
||||
const qs = next.toString();
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm text-muted-foreground">Year</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setYear("all")}
|
||||
className={[
|
||||
"h-9 rounded-md border px-3 text-sm",
|
||||
yearParam === "all" ? "bg-accent" : "hover:bg-accent/60",
|
||||
].join(" ")}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="default" className="h-11 gap-2">
|
||||
<FilterIcon className="h-4 w-4" />
|
||||
Filter
|
||||
{activeCount > 0 ? (
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{activeCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
{years.map((y) => {
|
||||
const active = yearParam === String(y);
|
||||
return (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
onClick={() => setYear(y)}
|
||||
className={[
|
||||
"h-9 rounded-md border px-3 text-sm",
|
||||
active ? "bg-accent" : "hover:bg-accent/60",
|
||||
].join(" ")}
|
||||
<DialogContent className="p-0 sm:max-w-xl">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle className="flex items-center justify-between gap-3">
|
||||
<span>Filter portfolio</span>
|
||||
{draftYear !== "all" || draftQ.trim().length ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAll}
|
||||
className="gap-2"
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<XIcon className="h-4 w-4" />
|
||||
Clear
|
||||
</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 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>
|
||||
</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>
|
||||
</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}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user