Files
SkinbaseNova/resources/js/components/gallery/MasonryGallery.jsx

357 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, {
useState, useEffect, useRef, useCallback, memo,
} from 'react';
import ArtworkCard from './ArtworkCard';
import './MasonryGallery.css';
// ── Masonry helpers ────────────────────────────────────────────────────────
const ROW_SIZE = 8;
const ROW_GAP = 16;
function applyMasonry(grid) {
if (!grid) return;
Array.from(grid.querySelectorAll('.nova-card')).forEach((card) => {
const media = card.querySelector('.nova-card-media') || card;
let height = media.getBoundingClientRect().height || 200;
// Clamp to the computed max-height so the span never over-reserves rows
// when CSS max-height kicks in (e.g. portrait images capped to 2×16:9).
const cssMaxH = parseFloat(getComputedStyle(media).maxHeight);
if (!isNaN(cssMaxH) && cssMaxH > 0 && cssMaxH < height) {
height = cssMaxH;
}
const span = Math.max(1, Math.ceil((height + ROW_GAP) / (ROW_SIZE + ROW_GAP)));
card.style.gridRowEnd = `span ${span}`;
});
}
function waitForImages(el) {
return Promise.all(
Array.from(el.querySelectorAll('img')).map((img) =>
img.decode ? img.decode().catch(() => null) : Promise.resolve(),
),
);
}
// ── Page fetch helpers ─────────────────────────────────────────────────────
/**
* Fetch the next page of data.
*
* The response is either:
* - JSON { artworks: [...], next_cursor: '...' } when X-Requested-With is
* sent and the controller returns JSON (future enhancement)
* - HTML page we parse [data-react-masonry-gallery] from it and read its
* data-artworks / data-next-cursor / data-next-page-url attributes.
*/
async function fetchPageData(url) {
const res = await fetch(url, {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const ct = res.headers.get('content-type') || '';
// JSON fast-path (if controller ever returns JSON)
if (ct.includes('application/json')) {
const json = await res.json();
return {
artworks: json.artworks ?? [],
nextCursor: json.next_cursor ?? null,
nextPageUrl: json.next_page_url ?? null,
};
}
// HTML: parse and extract mount-container data attributes
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const el = doc.querySelector('[data-react-masonry-gallery]');
if (!el) return { artworks: [], nextCursor: null, nextPageUrl: null };
let artworks = [];
try { artworks = JSON.parse(el.dataset.artworks || '[]'); } catch { /* empty */ }
return {
artworks,
nextCursor: el.dataset.nextCursor || null,
nextPageUrl: el.dataset.nextPageUrl || null,
};
}
// ── Skeleton row ──────────────────────────────────────────────────────────
function SkeletonCard() {
return <div className="nova-skeleton-card" aria-hidden="true" />;
}
// ── Ranking API helpers ───────────────────────────────────────────────────
/**
* Map a single ArtworkListResource item (from /api/rank/*) to the internal
* artwork object shape used by ArtworkCard.
*/
function mapRankApiArtwork(item) {
const w = item.dimensions?.width ?? null;
const h = item.dimensions?.height ?? null;
const thumb = item.thumbnail_url ?? null;
const webUrl = item.urls?.web ?? item.category?.url ?? null;
return {
id: item.id ?? null,
name: item.title ?? item.name ?? null,
thumb: thumb,
thumb_url: thumb,
uname: item.author?.name ?? '',
username: item.author?.username ?? item.author?.name ?? '',
avatar_url: item.author?.avatar_url ?? null,
category_name: item.category?.name ?? '',
category_slug: item.category?.slug ?? '',
slug: item.slug ?? '',
url: webUrl,
width: w,
height: h,
};
}
/**
* Fetch ranked artworks from the ranking API.
* Returns { artworks: [...] } in internal shape, or { artworks: [] } on failure.
*/
async function fetchRankApiArtworks(endpoint, rankType) {
try {
const url = new URL(endpoint, window.location.href);
if (rankType) url.searchParams.set('type', rankType);
const res = await fetch(url.toString(), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok) return { artworks: [] };
const json = await res.json();
const items = Array.isArray(json.data) ? json.data : [];
return { artworks: items.map(mapRankApiArtwork) };
} catch {
return { artworks: [] };
}
}
const SKELETON_COUNT = 10;
// ── Main component ────────────────────────────────────────────────────────
/**
* MasonryGallery
*
* Props (all optional set via data attributes in entry-masonry-gallery.jsx):
* artworks [] Initial artwork objects
* galleryType string Maps to data-gallery-type (e.g. 'trending')
* cursorEndpoint string|null Route for cursor-based feeds (e.g. For You)
* initialNextCursor string|null First cursor token
* initialNextPageUrl string|null First "next page" URL (page-based feeds)
* limit number Items per page (default 40)
* rankApiEndpoint string|null /api/rank/* endpoint; used as fallback data
* source when no SSR artworks are available
* rankType string|null Ranking API ?type= param (trending|new_hot|best)
*/
function MasonryGallery({
artworks: initialArtworks = [],
galleryType = 'discover',
cursorEndpoint = null,
initialNextCursor = null,
initialNextPageUrl = null,
limit = 40,
rankApiEndpoint = null,
rankType = null,
}) {
const [artworks, setArtworks] = useState(initialArtworks);
const [nextCursor, setNextCursor] = useState(initialNextCursor);
const [nextPageUrl, setNextPageUrl] = useState(initialNextPageUrl);
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(!initialNextCursor && !initialNextPageUrl);
const gridRef = useRef(null);
const triggerRef = useRef(null);
// ── Ranking API fallback ───────────────────────────────────────────────
// When the server-side render provides no initial artworks (e.g. cache miss
// or empty page result) and a ranking API endpoint is configured, perform a
// client-side fetch from the ranking API to hydrate the grid.
// Satisfies spec: "Fallback: Latest if ranking missing".
useEffect(() => {
if (initialArtworks.length > 0) return; // SSR artworks already present
if (!rankApiEndpoint) return; // no API endpoint configured
let cancelled = false;
setLoading(true);
fetchRankApiArtworks(rankApiEndpoint, rankType).then(({ artworks: ranked }) => {
if (cancelled) return;
if (ranked.length > 0) {
setArtworks(ranked);
setDone(true); // ranking API returns a full list; no further pagination
}
setLoading(false);
});
return () => { cancelled = true; };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Masonry re-layout ──────────────────────────────────────────────────
const relayout = useCallback(() => {
const g = gridRef.current;
if (!g) return;
applyMasonry(g);
waitForImages(g).then(() => applyMasonry(g));
}, []);
// Re-layout whenever artworks list changes.
// Defer by one requestAnimationFrame so the browser has resolved
// aspect-ratio heights before we measure with getBoundingClientRect().
useEffect(() => {
const raf = requestAnimationFrame(() => relayout());
return () => cancelAnimationFrame(raf);
}, [artworks, relayout]);
// Re-layout on container resize (column width changes)
useEffect(() => {
const g = gridRef.current;
if (!g || !('ResizeObserver' in window)) return;
const ro = new ResizeObserver(relayout);
ro.observe(g);
return () => ro.disconnect();
}, [relayout]);
// ── Load more ──────────────────────────────────────────────────────────
const fetchNext = useCallback(async () => {
if (loading || done) return;
// Build the URL to fetch
let fetchUrl = null;
if (cursorEndpoint && nextCursor) {
const u = new URL(cursorEndpoint, window.location.href);
u.searchParams.set('cursor', nextCursor);
u.searchParams.set('limit', String(limit));
fetchUrl = u.toString();
} else if (nextPageUrl) {
fetchUrl = nextPageUrl;
}
if (!fetchUrl) { setDone(true); return; }
setLoading(true);
try {
const { artworks: newItems, nextCursor: nc, nextPageUrl: np } =
await fetchPageData(fetchUrl);
if (!newItems.length) {
setDone(true);
} else {
setArtworks((prev) => [...prev, ...newItems]);
if (cursorEndpoint) {
setNextCursor(nc);
if (!nc) setDone(true);
} else {
setNextPageUrl(np);
if (!np) setDone(true);
}
}
} catch {
setDone(true);
} finally {
setLoading(false);
}
}, [loading, done, cursorEndpoint, nextCursor, nextPageUrl, limit]);
// ── Intersection observer for infinite scroll ──────────────────────────
useEffect(() => {
if (done) return;
const trigger = triggerRef.current;
if (!trigger || !('IntersectionObserver' in window)) return;
const io = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) fetchNext(); },
{ rootMargin: '900px', threshold: 0 },
);
io.observe(trigger);
return () => io.disconnect();
}, [done, fetchNext]);
// Gallery V2 spec §7: 5 col desktop / 3 tablet / 2 mobile for all gallery pages.
// Discover feeds (home/discover page) retain the same 5-col layout.
const gridClass = 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6';
// ── Render ─────────────────────────────────────────────────────────────
return (
<section
className="px-6 pb-10 pt-2 md:px-10 is-enhanced"
data-nova-gallery
data-gallery-type={galleryType}
data-react-masonry-gallery
data-artworks={JSON.stringify(artworks)}
data-next-cursor={nextCursor ?? undefined}
data-next-page-url={nextPageUrl ?? undefined}
>
{artworks.length > 0 ? (
<>
<div
ref={gridRef}
className={gridClass}
data-gallery-grid
>
{artworks.map((art, idx) => (
<ArtworkCard
key={`${art.id}-${idx}`}
art={art}
loading={idx < 8 ? 'eager' : 'lazy'}
fetchPriority={idx === 0 ? 'high' : undefined}
/>
))}
</div>
{/* Infinite scroll sentinel placed after the grid */}
{!done && (
<div
ref={triggerRef}
className="h-px w-full"
aria-hidden="true"
/>
)}
{/* Loading indicator */}
{loading && (
<div className="flex justify-center items-center gap-2 mt-8 py-4 text-white/30 text-sm">
<svg
className="animate-spin h-4 w-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12" cy="12" r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v8H4z"
/>
</svg>
Loading more
</div>
)}
{done && artworks.length > 0 && (
<p className="text-center text-xs text-white/20 mt-8 py-2">
All caught up
</p>
)}
</>
) : (
/* Empty state gallery-type-specific messaging handled by caller */
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
<p className="text-white/40 text-sm">No artworks found for this section yet.</p>
</div>
)}
</section>
);
}
export default memo(MasonryGallery);