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:
163
resources/js/components/gallery/ArtworkCard.jsx
Normal file
163
resources/js/components/gallery/ArtworkCard.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
function buildAvatarUrl(userId, avatarHash, size = 40) {
|
||||
if (!userId) return '/images/avatar-placeholder.jpg';
|
||||
if (!avatarHash) return `/avatar/default/${userId}?s=${size}`;
|
||||
return `/avatar/${userId}/${avatarHash}?s=${size}`;
|
||||
}
|
||||
|
||||
function slugify(str) {
|
||||
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* React version of resources/views/components/artwork-card.blade.php
|
||||
* Keeps identical HTML structure so existing CSS (nova-card, nova-card-media, etc.) applies.
|
||||
*/
|
||||
export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = null }) {
|
||||
const imgRef = useRef(null);
|
||||
|
||||
// Activate blur-preview class once image has decoded (mirrors nova.js behaviour)
|
||||
useEffect(() => {
|
||||
const img = imgRef.current;
|
||||
if (!img) return;
|
||||
const markLoaded = () => img.classList.add('is-loaded');
|
||||
if (img.complete && img.naturalWidth > 0) { markLoaded(); return; }
|
||||
img.addEventListener('load', markLoaded, { once: true });
|
||||
img.addEventListener('error', markLoaded, { once: true });
|
||||
}, []);
|
||||
|
||||
const title = (art.name || art.title || 'Untitled artwork').trim();
|
||||
const author = (art.uname || art.author_name || art.author || 'Skinbase').trim();
|
||||
const username = (art.username || art.uname || '').trim();
|
||||
const category = (art.category_name || art.category || '').trim();
|
||||
|
||||
const likes = art.likes ?? art.favourites ?? 0;
|
||||
const comments = art.comments_count ?? art.comment_count ?? 0;
|
||||
|
||||
const imgSrc = art.thumb || art.thumb_url || art.thumbnail_url || '/images/placeholder.jpg';
|
||||
const imgSrcset = art.thumb_srcset || imgSrc;
|
||||
|
||||
const cardUrl = art.url || (art.id ? `/art/${art.id}/${slugify(title)}` : '#');
|
||||
const authorUrl = username ? `/@${username.toLowerCase()}` : null;
|
||||
const avatarSrc = buildAvatarUrl(art.user_id, art.avatar_hash, 40);
|
||||
|
||||
const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0;
|
||||
const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null;
|
||||
|
||||
// Span 2 columns for panoramic images (AR > 2.0) in Photography or Wallpapers categories.
|
||||
// These slugs match the root categories; name-matching is kept as fallback.
|
||||
const wideCategories = ['photography', 'wallpapers', 'photography-digital', 'wallpaper'];
|
||||
const wideCategoryNames = ['photography', 'wallpapers'];
|
||||
const catSlug = (art.category_slug || '').toLowerCase();
|
||||
const catName = (art.category_name || '').toLowerCase();
|
||||
const isWideEligible =
|
||||
aspectRatio !== null &&
|
||||
aspectRatio > 2.0 &&
|
||||
(wideCategories.includes(catSlug) || wideCategoryNames.includes(catName));
|
||||
|
||||
const articleStyle = isWideEligible ? { gridColumn: 'span 2' } : {};
|
||||
const aspectStyle = hasDimensions ? { aspectRatio: `${art.width} / ${art.height}` } : {};
|
||||
// Image always fills the container absolutely – the container's height is
|
||||
// driven by aspect-ratio (capped by CSS max-height). Using absolute
|
||||
// positioning means width/height are always 100% of the capped box, so
|
||||
// object-cover crops top/bottom instead of leaving dark gaps.
|
||||
const imgClass = [
|
||||
'absolute inset-0 h-full w-full object-cover',
|
||||
'transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]',
|
||||
loading !== 'eager' ? 'blur-sm scale-[1.02] data-blur-preview' : '',
|
||||
].join(' ');
|
||||
|
||||
const metaParts = [];
|
||||
if (art.resolution) metaParts.push(art.resolution);
|
||||
else if (hasDimensions) metaParts.push(`${art.width}×${art.height}`);
|
||||
if (category) metaParts.push(category);
|
||||
if (art.license) metaParts.push(art.license);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`nova-card gallery-item artwork${isWideEligible ? ' nova-card--wide' : ''}`}
|
||||
style={articleStyle}
|
||||
data-art-id={art.id}
|
||||
data-art-url={cardUrl}
|
||||
data-art-title={title}
|
||||
data-art-img={imgSrc}
|
||||
>
|
||||
<a
|
||||
href={cardUrl}
|
||||
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
||||
>
|
||||
{category && (
|
||||
<div className="absolute left-3 top-3 z-30 rounded-md bg-black/55 px-2 py-1 text-xs text-white backdrop-blur-sm">
|
||||
{category}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* nova-card-media: height driven by aspect-ratio, capped by MasonryGallery.css max-height.
|
||||
w-full prevents browsers shrinking the width when max-height overrides aspect-ratio. */}
|
||||
<div
|
||||
className="nova-card-media relative w-full overflow-hidden bg-neutral-900"
|
||||
style={aspectStyle}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none" />
|
||||
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={imgSrc}
|
||||
srcSet={imgSrcset}
|
||||
sizes="(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw"
|
||||
loading={loading}
|
||||
decoding={loading === 'eager' ? 'sync' : 'async'}
|
||||
fetchPriority={fetchpriority || undefined}
|
||||
alt={title}
|
||||
width={hasDimensions ? art.width : undefined}
|
||||
height={hasDimensions ? art.height : undefined}
|
||||
className={imgClass}
|
||||
data-blur-preview={loading !== 'eager' ? '' : undefined}
|
||||
/>
|
||||
|
||||
{/* Hover badge row */}
|
||||
<div className="absolute right-3 top-3 z-30 flex items-center gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
<span className="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">
|
||||
View
|
||||
</span>
|
||||
{authorUrl && (
|
||||
<span className="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">
|
||||
Profile
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overlay caption */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
|
||||
<div className="truncate text-sm font-semibold text-white">{title}</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-3 text-xs text-white/80">
|
||||
<span className="truncate flex items-center gap-2">
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt={`Avatar of ${author}`}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="truncate">
|
||||
<span>{author}</span>
|
||||
{username && (
|
||||
<span className="text-white/60"> @{username}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0">❤ {likes} · 💬 {comments}</span>
|
||||
</div>
|
||||
{metaParts.length > 0 && (
|
||||
<div className="mt-1 text-[11px] text-white/70">
|
||||
{metaParts.join(' • ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="sr-only">{title} by {author}</span>
|
||||
</a>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
138
resources/js/components/gallery/MasonryGallery.css
Normal file
138
resources/js/components/gallery/MasonryGallery.css
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* MasonryGallery – scoped CSS
|
||||
*
|
||||
* Grid column definitions (activated when React adds .is-enhanced to the root).
|
||||
* Mirrors the blade @push('styles') blocks so the same rules apply whether the
|
||||
* page is rendered server-side or by the React component.
|
||||
*/
|
||||
|
||||
/* ── Masonry grid ─────────────────────────────────────────────────────────── */
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
grid-auto-rows: 8px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
[data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; }
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
[data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(6, minmax(0, 1fr)) !important; }
|
||||
}
|
||||
|
||||
@media (min-width: 2600px) {
|
||||
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
[data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(7, minmax(0, 1fr)) !important; }
|
||||
}
|
||||
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
|
||||
|
||||
/* ── Fallback aspect-ratio for cards without stored dimensions ───────────── */
|
||||
/*
|
||||
* When ArtworkCard has no width/height data it renders the img as h-auto,
|
||||
* meaning the container height is 0 until the image loads. Setting a
|
||||
* default aspect-ratio here reserves approximate space immediately and
|
||||
* prevents applyMasonry from calculating span=1 → then jumping on load.
|
||||
* Cards with an inline aspect-ratio style (from real dimensions) override this.
|
||||
*/
|
||||
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
|
||||
aspect-ratio: 3 / 2;
|
||||
width: 100%; /* prevent aspect-ratio + max-height from shrinking the column width */
|
||||
}
|
||||
|
||||
/* Override: when an inline aspect-ratio is set by ArtworkCard those values */
|
||||
/* take precedence naturally (inline style > class). No extra selector needed. */
|
||||
|
||||
/* ── Card max-height cap ──────────────────────────────────────────────────── */
|
||||
/*
|
||||
* Limits any single card to the height of 2 stacked 16:9 images in its column.
|
||||
* Formula: 2 × (col_width × 9/16) = col_width × 9/8
|
||||
*
|
||||
* 5-col (lg+): col_width = (100vw - 80px_padding - 4×24px_gaps) / 5
|
||||
* = (100vw - 176px) / 5
|
||||
* max-height = (100vw - 176px) / 5 × 9/8
|
||||
* = (100vw - 176px) × 0.225
|
||||
*
|
||||
* 2-col (md): col_width = (100vw - 80px - 1×24px) / 2
|
||||
* = (100vw - 104px) / 2
|
||||
* max-height = (100vw - 104px) / 2 × 9/8
|
||||
* = (100vw - 104px) × 0.5625
|
||||
*
|
||||
* 1-col mobile: uncapped – portrait images are fine filling the full width.
|
||||
*/
|
||||
|
||||
/* Global selector covers both the React-rendered gallery and the blade fallback */
|
||||
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
|
||||
overflow: hidden; /* ensure img is clipped at max-height */
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
|
||||
/* 5-column layout: 2 × (col_width × 9/16) = col_width × 9/8 */
|
||||
max-height: calc((100vw - 176px) * 9 / 40);
|
||||
}
|
||||
/* Wide (2-col spanning) cards get double the column width */
|
||||
[data-nova-gallery] [data-gallery-grid] .nova-card--wide .nova-card-media {
|
||||
max-height: calc((100vw - 176px) * 9 / 20);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
|
||||
/* 2-column layout */
|
||||
max-height: calc((100vw - 104px) * 9 / 16);
|
||||
}
|
||||
[data-nova-gallery] [data-gallery-grid] .nova-card--wide .nova-card-media {
|
||||
/* 2-col span fills full width on md breakpoint */
|
||||
max-height: calc((100vw - 104px) * 9 / 8);
|
||||
}
|
||||
}
|
||||
|
||||
/* Image is positioned absolutely inside the container so it always fills
|
||||
the capped box (max-height), cropping top/bottom via object-fit: cover. */
|
||||
[data-nova-gallery] [data-gallery-grid] .nova-card-media img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
|
||||
.nova-skeleton-card {
|
||||
border-radius: 1rem;
|
||||
min-height: 180px;
|
||||
background: linear-gradient(
|
||||
110deg,
|
||||
rgba(255, 255, 255, 0.06) 8%,
|
||||
rgba(255, 255, 255, 0.12) 18%,
|
||||
rgba(255, 255, 255, 0.06) 33%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: novaShimmer 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes novaShimmer {
|
||||
to { background-position-x: -200%; }
|
||||
}
|
||||
|
||||
/* ── Card enter animation (appended by infinite scroll) ───────────────────── */
|
||||
.nova-card-enter { opacity: 0; transform: translateY(8px); }
|
||||
.nova-card-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 200ms ease-out, transform 200ms ease-out;
|
||||
}
|
||||
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);
|
||||
53
resources/js/entry-masonry-gallery.jsx
Normal file
53
resources/js/entry-masonry-gallery.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Entry point for the MasonryGallery React component.
|
||||
*
|
||||
* Looks for every element with [data-react-masonry-gallery] on the page and
|
||||
* mounts a MasonryGallery instance into it. All configuration is passed via
|
||||
* data attributes so the Blade view does not need to know anything about React.
|
||||
*
|
||||
* Expected data attributes on the mount element:
|
||||
* data-artworks JSON array of artwork objects (required)
|
||||
* data-gallery-type e.g. "trending", "for-you" (default: "discover")
|
||||
* data-cursor-endpoint URL for cursor-based feeds (optional)
|
||||
* data-next-cursor Initial cursor token (optional)
|
||||
* data-next-page-url Initial "next page" URL (optional)
|
||||
* data-limit Items per page (default: 40)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import MasonryGallery from './components/gallery/MasonryGallery';
|
||||
|
||||
function mountAll() {
|
||||
document
|
||||
.querySelectorAll('[data-react-masonry-gallery]')
|
||||
.forEach((container) => {
|
||||
// Already mounted by a previous call (e.g. HMR)
|
||||
if (container.dataset.reactMounted) return;
|
||||
container.dataset.reactMounted = '1';
|
||||
|
||||
let artworks = [];
|
||||
try {
|
||||
artworks = JSON.parse(container.dataset.artworks || '[]');
|
||||
} catch {
|
||||
console.warn('[MasonryGallery] Could not parse data-artworks JSON');
|
||||
}
|
||||
|
||||
const props = {
|
||||
artworks,
|
||||
galleryType: container.dataset.galleryType || 'discover',
|
||||
cursorEndpoint: container.dataset.cursorEndpoint || null,
|
||||
initialNextCursor: container.dataset.nextCursor || null,
|
||||
initialNextPageUrl: container.dataset.nextPageUrl || null,
|
||||
limit: parseInt(container.dataset.limit || '40', 10),
|
||||
};
|
||||
|
||||
createRoot(container).render(<MasonryGallery {...props} />);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mountAll);
|
||||
} else {
|
||||
mountAll();
|
||||
}
|
||||
Reference in New Issue
Block a user