Files
SkinbaseNova/resources/js/components/gallery/MasonryGallery.jsx
Gregor Klevze 67ef79766c fix(gallery): fill tall portrait cards to full block width with object-cover crop
- 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
2026-02-27 13:34:08 +01:00

278 lines
9.9 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" />;
}
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);