- ArtworkCard: add w-full to nova-card-media, use absolute inset-0 on img so object-cover fills the max-height capped box instead of collapsing the width - MasonryGallery.css: add width:100% to media container, position img absolutely so top/bottom is cropped rather than leaving dark gaps - Add React MasonryGallery + ArtworkCard components and entry point - Add recommendation system: UserRecoProfile model/DTO/migration, SuggestedCreatorsController, SuggestedTagsController, Recommendation services, config/recommendations.php - SimilarArtworksController, DiscoverController, HomepageService updates - Update routes (api + web) and discover/for-you views - Refresh favicon assets, update vite.config.js
278 lines
9.9 KiB
JavaScript
278 lines
9.9 KiB
JavaScript
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" />;
|
||
}
|
||
|
||
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)
|
||
*/
|
||
function MasonryGallery({
|
||
artworks: initialArtworks = [],
|
||
galleryType = 'discover',
|
||
cursorEndpoint = null,
|
||
initialNextCursor = null,
|
||
initialNextPageUrl = null,
|
||
limit = 40,
|
||
}) {
|
||
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);
|
||
|
||
// ── 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]);
|
||
|
||
// ── 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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 force-5"
|
||
data-gallery-grid
|
||
>
|
||
{artworks.map((art, idx) => (
|
||
<ArtworkCard
|
||
key={`${art.id}-${idx}`}
|
||
art={art}
|
||
loading={idx < 8 ? 'eager' : 'lazy'}
|
||
fetchpriority={idx === 0 ? 'high' : null}
|
||
/>
|
||
))}
|
||
</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);
|