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:
277
resources/js/components/gallery/MasonryGallery.jsx
Normal file
277
resources/js/components/gallery/MasonryGallery.jsx
Normal 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);
|
||||
Reference in New Issue
Block a user