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
This commit is contained in:
2026-02-27 13:34:08 +01:00
parent 09eadf9003
commit 67ef79766c
37 changed files with 3096 additions and 58 deletions

View File

@@ -0,0 +1,277 @@
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);