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();
|
||||
}
|
||||
110
resources/views/layouts/_navigation.blade.php
Normal file
110
resources/views/layouts/_navigation.blade.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
|
||||
<!-- Primary Navigation Menu -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<!-- Logo -->
|
||||
<div class="shrink-0 flex items-center">
|
||||
<a href="{{ route('dashboard') }}">
|
||||
<x-application-logo class="block h-9 w-auto fill-current text-gray-800" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
|
||||
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||
{{ __('Dashboard') }}
|
||||
</x-nav-link>
|
||||
@auth
|
||||
<x-nav-link :href="route('discover.for-you')" :active="request()->routeIs('discover.for-you')">
|
||||
{{ __('For You') }}
|
||||
</x-nav-link>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Dropdown -->
|
||||
<div class="hidden sm:flex sm:items-center sm:ms-6">
|
||||
<x-dropdown align="right" width="48">
|
||||
<x-slot name="trigger">
|
||||
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
|
||||
<div>{{ Auth::user()->name }}</div>
|
||||
|
||||
<div class="ms-1">
|
||||
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="content">
|
||||
<x-dropdown-link :href="route('profile.edit')">
|
||||
{{ __('Profile') }}
|
||||
</x-dropdown-link>
|
||||
|
||||
<!-- Authentication -->
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
|
||||
<x-dropdown-link :href="route('logout')"
|
||||
onclick="event.preventDefault();
|
||||
this.closest('form').submit();">
|
||||
{{ __('Log Out') }}
|
||||
</x-dropdown-link>
|
||||
</form>
|
||||
</x-slot>
|
||||
</x-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- Hamburger -->
|
||||
<div class="-me-2 flex items-center sm:hidden">
|
||||
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
|
||||
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Responsive Navigation Menu -->
|
||||
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
|
||||
<div class="pt-2 pb-3 space-y-1">
|
||||
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||
{{ __('Dashboard') }}
|
||||
</x-responsive-nav-link>
|
||||
@auth
|
||||
<x-responsive-nav-link :href="route('discover.for-you')" :active="request()->routeIs('discover.for-you')">
|
||||
{{ __('For You') }}
|
||||
</x-responsive-nav-link>
|
||||
@endauth
|
||||
</div>
|
||||
|
||||
<!-- Responsive Settings Options -->
|
||||
<div class="pt-4 pb-1 border-t border-gray-200">
|
||||
<div class="px-4">
|
||||
<div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
|
||||
<div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-1">
|
||||
<x-responsive-nav-link :href="route('profile.edit')">
|
||||
{{ __('Profile') }}
|
||||
</x-responsive-nav-link>
|
||||
|
||||
<!-- Authentication -->
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
|
||||
<x-responsive-nav-link :href="route('logout')"
|
||||
onclick="event.preventDefault();
|
||||
this.closest('form').submit();">
|
||||
{{ __('Log Out') }}
|
||||
</x-responsive-nav-link>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -26,7 +26,13 @@
|
||||
|
||||
<!-- Icons (kept for now to preserve current visual output) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
|
||||
<link rel="shortcut icon" href="/favicon.ico">
|
||||
|
||||
<link rel="icon" type="image/png" href="/favicon/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||
|
||||
|
||||
@vite(['resources/css/app.css','resources/css/nova-grid.css','resources/scss/nova.scss','resources/js/nova.js','resources/js/entry-search.jsx'])
|
||||
<style>
|
||||
|
||||
@@ -52,6 +52,11 @@
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/on-this-day">
|
||||
<i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day
|
||||
</a>
|
||||
@auth
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('discover.for-you') }}">
|
||||
<i class="fa-solid fa-wand-magic-sparkles w-4 text-center"></i>For You
|
||||
</a>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -148,6 +153,12 @@
|
||||
|
||||
<!-- Notification icons -->
|
||||
<div class="hidden md:flex items-center gap-1 text-soft">
|
||||
<a href="{{ route('discover.for-you') }}"
|
||||
class="relative w-10 h-10 inline-flex items-center justify-center rounded-lg transition-colors {{ request()->routeIs('discover.for-you') ? 'bg-yellow-500/15 text-yellow-300' : 'hover:bg-white/5' }}"
|
||||
title="For You">
|
||||
<i class="fa-solid fa-wand-magic-sparkles w-5 h-5 text-[1.1rem] {{ request()->routeIs('discover.for-you') ? 'text-yellow-300' : 'text-sb-muted' }}"></i>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('dashboard.favorites') }}"
|
||||
class="relative w-10 h-10 inline-flex items-center justify-center rounded-lg hover:bg-white/5"
|
||||
title="Favourites">
|
||||
@@ -316,6 +327,9 @@
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/top-rated"><i class="fa-solid fa-medal w-4 text-center text-sb-muted"></i>Top Rated</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/most-downloaded"><i class="fa-solid fa-download w-4 text-center text-sb-muted"></i>Most Downloaded</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/on-this-day"><i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day</a>
|
||||
@auth
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('discover.for-you') }}"><i class="fa-solid fa-wand-magic-sparkles w-4 text-center text-yellow-400/70"></i>For You</a>
|
||||
@endauth
|
||||
|
||||
<div class="pt-3 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Browse</div>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/browse"><i class="fa-solid fa-border-all w-4 text-center text-sb-muted"></i>All Artworks</a>
|
||||
|
||||
114
resources/views/web/discover/for-you.blade.php
Normal file
114
resources/views/web/discover/for-you.blade.php
Normal file
@@ -0,0 +1,114 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- ── Hero header ── --}}
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Discover</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight flex items-center gap-3">
|
||||
<i class="fa-solid fa-wand-magic-sparkles text-yellow-400 text-2xl"></i>
|
||||
For You
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-white/50">Artworks picked for you based on your taste.</p>
|
||||
</div>
|
||||
|
||||
{{-- Section switcher pills --}}
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<a href="{{ route('discover.for-you') }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-yellow-500/20 text-yellow-300 border border-yellow-400/20">
|
||||
<i class="fa-solid fa-wand-magic-sparkles text-xs"></i>
|
||||
For You
|
||||
</a>
|
||||
@php
|
||||
$sections = [
|
||||
'trending' => ['label' => 'Trending', 'icon' => 'fa-fire'],
|
||||
'fresh' => ['label' => 'Fresh', 'icon' => 'fa-bolt'],
|
||||
'top-rated' => ['label' => 'Top Rated', 'icon' => 'fa-medal'],
|
||||
'most-downloaded' => ['label' => 'Most Downloaded', 'icon' => 'fa-download'],
|
||||
];
|
||||
@endphp
|
||||
@foreach($sections as $slug => $meta)
|
||||
<a href="{{ route('discover.' . $slug) }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white">
|
||||
<i class="fa-solid {{ $meta['icon'] }} text-xs"></i>
|
||||
{{ $meta['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Artwork grid (React MasonryGallery) ── --}}
|
||||
@php
|
||||
$galleryArtworks = $artworks->map(fn ($art) => [
|
||||
'id' => $art->id,
|
||||
'name' => $art->name ?? null,
|
||||
'thumb' => $art->thumb_url ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? '',
|
||||
'username' => $art->uname ?? '',
|
||||
'category_name' => $art->category_name ?? '',
|
||||
'category_slug' => $art->category_slug ?? '',
|
||||
'slug' => $art->slug ?? '',
|
||||
'width' => $art->width ?? null,
|
||||
'height' => $art->height ?? null,
|
||||
])->values();
|
||||
@endphp
|
||||
<div
|
||||
data-react-masonry-gallery
|
||||
data-artworks="{{ json_encode($galleryArtworks) }}"
|
||||
data-gallery-type="for-you"
|
||||
data-cursor-endpoint="{{ route('discover.for-you') }}"
|
||||
@if (!empty($next_cursor)) data-next-cursor="{{ $next_cursor }}" @endif
|
||||
data-limit="40"
|
||||
class="min-h-32"
|
||||
></div>
|
||||
|
||||
@endsection
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
[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-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-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-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; }
|
||||
[data-gallery-skeleton].is-loading { display: grid !important; grid-template-columns: inherit; gap: 1rem; }
|
||||
.nova-skeleton-card {
|
||||
border-radius: 1rem;
|
||||
min-height: 180px;
|
||||
background: linear-gradient(110deg, rgba(255,255,255,.06) 8%, rgba(255,255,255,.12) 18%, rgba(255,255,255,.06) 33%);
|
||||
background-size: 200% 100%;
|
||||
animation: novaShimmer 1.2s linear infinite;
|
||||
}
|
||||
@keyframes novaShimmer {
|
||||
to { background-position-x: -200%; }
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||
@endpush
|
||||
@@ -18,6 +18,13 @@
|
||||
|
||||
{{-- Section switcher pills --}}
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
@auth
|
||||
<a href="{{ route('discover.for-you') }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white">
|
||||
<i class="fa-solid fa-wand-magic-sparkles text-xs text-yellow-400/80"></i>
|
||||
For You
|
||||
</a>
|
||||
@endauth
|
||||
@php
|
||||
$sections = [
|
||||
'trending' => ['label' => 'Trending', 'icon' => 'fa-fire'],
|
||||
@@ -40,34 +47,105 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Artwork grid ── --}}
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
@if ($artworks && $artworks->isNotEmpty())
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-5">
|
||||
@foreach ($artworks as $art)
|
||||
@php
|
||||
$card = (object)[
|
||||
'id' => $art->id,
|
||||
'name' => $art->name,
|
||||
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? '',
|
||||
'category_name' => $art->category_name ?? '',
|
||||
];
|
||||
@endphp
|
||||
<x-artwork-card :art="$card" />
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $artworks->withQueryString()->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">No artworks found for this section yet.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
{{-- ── Artwork grid (React MasonryGallery) ── --}}
|
||||
@php
|
||||
$galleryArtworks = collect($artworks->items())->map(fn ($art) => [
|
||||
'id' => $art->id,
|
||||
'name' => $art->name ?? null,
|
||||
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? '',
|
||||
'username' => $art->uname ?? '',
|
||||
'category_name' => $art->category_name ?? '',
|
||||
'category_slug' => $art->category_slug ?? '',
|
||||
'slug' => $art->slug ?? '',
|
||||
'width' => $art->width ?? null,
|
||||
'height' => $art->height ?? null,
|
||||
])->values();
|
||||
$galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
|
||||
@endphp
|
||||
<div
|
||||
data-react-masonry-gallery
|
||||
data-artworks="{{ json_encode($galleryArtworks) }}"
|
||||
data-gallery-type="{{ $section ?? 'discover' }}"
|
||||
@if ($galleryNextPageUrl) data-next-page-url="{{ $galleryNextPageUrl }}" @endif
|
||||
data-limit="24"
|
||||
class="min-h-32"
|
||||
></div>
|
||||
|
||||
@endsection
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
[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-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-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-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; }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-pagination] {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
[data-nova-gallery].is-enhanced [data-gallery-pagination] ul {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
[data-nova-gallery].is-enhanced [data-gallery-pagination] li a,
|
||||
[data-nova-gallery].is-enhanced [data-gallery-pagination] li span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
background: rgba(255,255,255,0.03);
|
||||
color: #e6eef8;
|
||||
border: 1px solid rgba(255,255,255,0.04);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
[data-gallery-skeleton].is-loading { display: grid !important; grid-template-columns: inherit; gap: 1rem; }
|
||||
.nova-skeleton-card {
|
||||
border-radius: 1rem;
|
||||
min-height: 180px;
|
||||
background: linear-gradient(110deg, rgba(255,255,255,.06) 8%, rgba(255,255,255,.12) 18%, rgba(255,255,255,.06) 33%);
|
||||
background-size: 200% 100%;
|
||||
animation: novaShimmer 1.2s linear infinite;
|
||||
}
|
||||
@keyframes novaShimmer {
|
||||
to { background-position-x: -200%; }
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||
@endpush
|
||||
|
||||
Reference in New Issue
Block a user