feat: add reusable gallery carousel and ranking feed infrastructure

This commit is contained in:
2026-02-28 07:56:25 +01:00
parent 67ef79766c
commit 6536d4ae78
36 changed files with 3177 additions and 373 deletions

View File

@@ -21,6 +21,17 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
// Navigable state — updated on client-side navigation
const [artwork, setArtwork] = useState(initialArtwork)
const [liveStats, setLiveStats] = useState(initialArtwork?.stats || {})
const handleStatsChange = useCallback((delta) => {
setLiveStats(prev => {
const next = { ...prev }
Object.entries(delta).forEach(([key, val]) => {
next[key] = Math.max(0, (Number(next[key]) || 0) + val)
})
return next
})
}, [])
const [presentMd, setPresentMd] = useState(initialMd)
const [presentLg, setPresentLg] = useState(initialLg)
const [presentXl, setPresentXl] = useState(initialXl)
@@ -38,6 +49,7 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
*/
const handleNavigate = useCallback((data) => {
setArtwork(data)
setLiveStats(data.stats || {})
setPresentMd(data.thumbs?.md ?? null)
setPresentLg(data.thumbs?.lg ?? null)
setPresentXl(data.thumbs?.xl ?? null)
@@ -69,14 +81,14 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
<div className="mt-6 space-y-4 lg:hidden">
<ArtworkAuthor artwork={artwork} presentSq={presentSq} />
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} mobilePriority />
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} mobilePriority onStatsChange={handleStatsChange} />
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
</div>
<div className="mt-8 grid grid-cols-1 gap-8 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<ArtworkMeta artwork={artwork} />
<ArtworkStats artwork={artwork} />
<ArtworkStats artwork={artwork} stats={liveStats} />
<ArtworkTags artwork={artwork} />
<ArtworkDescription artwork={artwork} />
<ArtworkReactions artworkId={artwork.id} isLoggedIn={isAuthenticated} />
@@ -91,7 +103,7 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
<aside className="hidden space-y-6 lg:block">
<div className="sticky top-24 space-y-4">
<ArtworkAuthor artwork={artwork} presentSq={presentSq} />
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} />
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} onStatsChange={handleStatsChange} />
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
</div>
</aside>

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'
export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = false }) {
export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = false, onStatsChange }) {
const [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked))
const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited))
const [reporting, setReporting] = useState(false)
@@ -17,11 +17,16 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
if (!artwork?.id) return
const key = `sb_viewed_${artwork.id}`
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(key)) return
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, '1')
fetch(`/api/art/${artwork.id}/view`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
credentials: 'same-origin',
}).then(res => {
// Only mark as seen after a confirmed success — if the POST fails the
// next page load will retry rather than silently skipping forever.
if (res.ok && typeof sessionStorage !== 'undefined') {
sessionStorage.setItem(key, '1')
}
}).catch(() => {})
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -81,6 +86,7 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
setLiked(nextState)
try {
await postInteraction(`/api/artworks/${artwork.id}/like`, { state: nextState })
onStatsChange?.({ likes: nextState ? 1 : -1 })
} catch {
setLiked(!nextState)
}
@@ -91,6 +97,7 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
setFavorited(nextState)
try {
await postInteraction(`/api/artworks/${artwork.id}/favorite`, { state: nextState })
onStatsChange?.({ favorites: nextState ? 1 : -1 })
} catch {
setFavorited(!nextState)
}

View File

@@ -7,8 +7,8 @@ function formatCount(value) {
return `${number}`
}
export default function ArtworkStats({ artwork }) {
const stats = artwork?.stats || {}
export default function ArtworkStats({ artwork, stats: statsProp }) {
const stats = statsProp || artwork?.stats || {}
const width = artwork?.dimensions?.width || 0
const height = artwork?.dimensions?.height || 0

View File

@@ -1,11 +1,5 @@
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, '');
}
@@ -15,17 +9,8 @@ function slugify(str) {
* 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 imgRef = useRef(null);
const mediaRef = useRef(null);
const title = (art.name || art.title || 'Untitled artwork').trim();
const author = (art.uname || art.author_name || art.author || 'Skinbase').trim();
@@ -40,11 +25,35 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
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);
// Use pre-computed CDN URL from the server; JS fallback mirrors AvatarUrl::default()
const cdnBase = 'https://files.skinbase.org';
const avatarSrc = art.avatar_url || `${cdnBase}/avatars/default.webp`;
const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0;
const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null;
// Activate blur-preview class once image has decoded (mirrors nova.js behaviour).
// If the server didn't supply dimensions (old artworks with width=0/height=0),
// read naturalWidth/naturalHeight from the loaded image and imperatively set
// the container's aspect-ratio so the masonry ResizeObserver picks up real proportions.
useEffect(() => {
const img = imgRef.current;
const media = mediaRef.current;
if (!img) return;
const markLoaded = () => {
img.classList.add('is-loaded');
// If no server-side dimensions, apply real ratio from the decoded image
if (media && !hasDimensions && img.naturalWidth > 0 && img.naturalHeight > 0) {
media.style.aspectRatio = `${img.naturalWidth} / ${img.naturalHeight}`;
}
};
if (img.complete && img.naturalWidth > 0) { markLoaded(); return; }
img.addEventListener('load', markLoaded, { once: true });
img.addEventListener('error', markLoaded, { once: true });
}, []);
// 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'];
@@ -63,6 +72,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
// positioning means width/height are always 100% of the capped box, so
// object-cover crops top/bottom instead of leaving dark gaps.
const imgClass = [
'nova-card-main-image',
'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' : '',
@@ -76,7 +86,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
return (
<article
className={`nova-card gallery-item artwork${isWideEligible ? ' nova-card--wide' : ''}`}
className={`nova-card gallery-item artwork relative${isWideEligible ? ' nova-card--wide' : ''}`}
style={articleStyle}
data-art-id={art.id}
data-art-url={cardUrl}
@@ -85,17 +95,18 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
>
<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"
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20
shadow-lg shadow-black/40
transition-all duration-300 ease-out
hover:scale-[1.02] hover:-translate-y-px hover:ring-white/15
hover:shadow-[0_8px_30px_rgba(0,0,0,0.6),0_0_0_1px_rgba(255,255,255,0.08)]
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
style={{ willChange: 'transform' }}
>
{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
ref={mediaRef}
className="nova-card-media relative w-full overflow-hidden bg-neutral-900"
style={aspectStyle}
>
@@ -116,18 +127,6 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
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>
@@ -136,7 +135,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
<img
src={avatarSrc}
alt={`Avatar of ${author}`}
className="w-6 h-6 rounded-full object-cover"
className="w-6 h-6 shrink-0 rounded-full object-cover"
loading="lazy"
/>
<span className="truncate">
@@ -158,6 +157,41 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
<span className="sr-only">{title} by {author}</span>
</a>
{/* ── Quick actions: top-right, shown on card hover via CSS ─────── */}
<div className="nb-card-actions" aria-hidden="true">
<button
type="button"
className="nb-card-action-btn"
title="Favourite"
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// Favourite action wired to API in future iteration
}}
>
</button>
<a
href={`${cardUrl}?download=1`}
className="nb-card-action-btn"
title="Download"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
</a>
<a
href={cardUrl}
className="nb-card-action-btn"
title="Quick view"
tabIndex={-1}
>
👁
</a>
</div>
</article>
);
}

View File

@@ -0,0 +1,158 @@
.nb-react-carousel {
position: relative;
display: flex;
align-items: center;
min-height: 56px;
overflow: hidden;
}
.nb-react-viewport {
flex: 1 1 auto;
overflow: hidden;
height: 100%;
display: flex;
align-items: center;
white-space: nowrap;
}
.nb-react-strip {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap !important;
white-space: nowrap;
width: max-content;
min-width: max-content;
max-width: none;
padding: 0.6rem 3rem;
will-change: transform;
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
cursor: grab;
user-select: none;
touch-action: pan-x;
}
.nb-react-strip.is-dragging {
transition: none;
cursor: grabbing;
}
.nb-react-fade {
position: absolute;
top: 0;
bottom: 0;
width: 80px;
z-index: 2;
pointer-events: none;
opacity: 0;
transition: opacity 200ms ease;
}
.nb-react-fade--left {
left: 0;
background: linear-gradient(to right, rgba(15,23,36,0.95) 0%, rgba(15,23,36,0.6) 50%, transparent 100%);
}
.nb-react-fade--right {
right: 0;
background: linear-gradient(to left, rgba(15,23,36,0.95) 0%, rgba(15,23,36,0.6) 50%, transparent 100%);
}
.nb-react-carousel:not(.at-start) .nb-react-fade--left {
opacity: 1;
}
.nb-react-carousel:not(.at-end) .nb-react-fade--right {
opacity: 1;
}
.nb-react-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 9999px;
background: rgba(15,23,36,0.9);
border: 1px solid rgba(255,255,255,0.18);
color: rgba(255,255,255,0.85);
cursor: pointer;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 200ms ease, background 150ms ease, transform 150ms ease;
-webkit-tap-highlight-color: transparent;
}
.nb-react-arrow--left {
left: 8px;
}
.nb-react-arrow--right {
right: 8px;
}
.nb-react-arrow:hover {
background: rgba(30,46,68,0.98);
color: #fff;
transform: translateY(-50%) scale(1.1);
}
.nb-react-arrow:active {
transform: translateY(-50%) scale(0.93);
}
.nb-react-carousel:not(.at-start) .nb-react-arrow--left {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.nb-react-carousel:not(.at-end) .nb-react-arrow--right {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.nb-react-pill {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
line-height: 1;
border-radius: 9999px;
padding: 0.35rem 1rem;
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap !important;
text-decoration: none;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.08);
color: rgba(200,215,230,0.85);
transition: background 150ms ease, border-color 150ms ease, color 150ms ease, transform 150ms ease, box-shadow 150ms ease;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.nb-react-pill:hover {
background: rgba(255,255,255,0.15);
border-color: rgba(255,255,255,0.25);
color: #fff;
transform: translateY(-1px);
}
.nb-react-pill--active {
background: linear-gradient(135deg, #E07A21 0%, #c9650f 100%);
border-color: rgba(224,122,33,0.6);
color: #fff;
box-shadow: 0 2px 12px rgba(224,122,33,0.35), 0 0 0 1px rgba(224,122,33,0.2) inset;
transform: none;
}
.nb-react-pill--active:hover {
background: linear-gradient(135deg, #f08830 0%, #d9720f 100%);
transform: none;
}

View File

@@ -0,0 +1,282 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import './CategoryPillCarousel.css';
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
export default function CategoryPillCarousel({
items = [],
ariaLabel = 'Filter by category',
className = '',
}) {
const viewportRef = useRef(null);
const stripRef = useRef(null);
const animationRef = useRef(0);
const dragStateRef = useRef({
active: false,
moved: false,
pointerId: null,
startX: 0,
startOffset: 0,
});
const [offset, setOffset] = useState(0);
const [dragging, setDragging] = useState(false);
const [maxScroll, setMaxScroll] = useState(0);
const activeIndex = useMemo(() => {
const idx = items.findIndex((item) => !!item.active);
return idx >= 0 ? idx : 0;
}, [items]);
const maxOffset = useCallback(() => {
const viewport = viewportRef.current;
const strip = stripRef.current;
if (!viewport || !strip) return 0;
return Math.max(0, strip.scrollWidth - viewport.clientWidth);
}, []);
const recalcBounds = useCallback(() => {
const max = maxOffset();
setMaxScroll(max);
setOffset((prev) => clamp(prev, -max, 0));
}, [maxOffset]);
const moveTo = useCallback((nextOffset) => {
const max = maxOffset();
const clamped = clamp(nextOffset, -max, 0);
setOffset(clamped);
}, [maxOffset]);
const animateTo = useCallback((targetOffset, duration = 380) => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = 0;
}
const max = maxOffset();
const target = clamp(targetOffset, -max, 0);
const start = offset;
const delta = target - start;
if (Math.abs(delta) < 1) {
setOffset(target);
return;
}
const startTime = performance.now();
setDragging(false);
const easeOutCubic = (t) => 1 - ((1 - t) ** 3);
const step = (now) => {
const elapsed = now - startTime;
const progress = Math.min(1, elapsed / duration);
const eased = easeOutCubic(progress);
setOffset(start + (delta * eased));
if (progress < 1) {
animationRef.current = requestAnimationFrame(step);
} else {
animationRef.current = 0;
setOffset(target);
}
};
animationRef.current = requestAnimationFrame(step);
}, [maxOffset, offset]);
const moveToPill = useCallback((direction) => {
const strip = stripRef.current;
if (!strip) return;
const pills = Array.from(strip.querySelectorAll('.nb-react-pill'));
if (!pills.length) return;
const viewLeft = -offset;
if (direction > 0) {
const next = pills.find((pill) => pill.offsetLeft > viewLeft + 6);
if (next) animateTo(-next.offsetLeft);
else animateTo(-maxOffset());
return;
}
for (let i = pills.length - 1; i >= 0; i -= 1) {
const left = pills[i].offsetLeft;
if (left < viewLeft - 6) {
animateTo(-left);
return;
}
}
animateTo(0);
}, [animateTo, maxOffset, offset]);
useEffect(() => {
const viewport = viewportRef.current;
const strip = stripRef.current;
if (!viewport || !strip) return;
const activeEl = strip.querySelector('[data-active-pill="true"]');
if (!activeEl) {
moveTo(0);
return;
}
const centered = -(activeEl.offsetLeft - (viewport.clientWidth / 2) + (activeEl.offsetWidth / 2));
moveTo(centered);
recalcBounds();
}, [activeIndex, items, moveTo, recalcBounds]);
useEffect(() => {
const viewport = viewportRef.current;
const strip = stripRef.current;
if (!viewport || !strip) return;
const measure = () => recalcBounds();
const rafId = requestAnimationFrame(measure);
window.addEventListener('resize', measure, { passive: true });
let ro = null;
if ('ResizeObserver' in window) {
ro = new ResizeObserver(measure);
ro.observe(viewport);
ro.observe(strip);
}
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener('resize', measure);
if (ro) ro.disconnect();
};
}, [items, recalcBounds]);
useEffect(() => {
const strip = stripRef.current;
if (!strip) return;
const onPointerDown = (event) => {
if (event.pointerType === 'mouse' && event.button !== 0) return;
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = 0;
}
dragStateRef.current.active = true;
dragStateRef.current.moved = false;
dragStateRef.current.pointerId = event.pointerId;
dragStateRef.current.startX = event.clientX;
dragStateRef.current.startOffset = offset;
setDragging(true);
if (strip.setPointerCapture) {
try { strip.setPointerCapture(event.pointerId); } catch (_) { /* no-op */ }
}
event.preventDefault();
};
const onPointerMove = (event) => {
const state = dragStateRef.current;
if (!state.active || state.pointerId !== event.pointerId) return;
const dx = event.clientX - state.startX;
if (Math.abs(dx) > 3) state.moved = true;
moveTo(state.startOffset + dx);
};
const onPointerUpOrCancel = (event) => {
const state = dragStateRef.current;
if (!state.active || state.pointerId !== event.pointerId) return;
state.active = false;
state.pointerId = null;
setDragging(false);
if (strip.releasePointerCapture) {
try { strip.releasePointerCapture(event.pointerId); } catch (_) { /* no-op */ }
}
};
const onClickCapture = (event) => {
if (!dragStateRef.current.moved) return;
event.preventDefault();
event.stopPropagation();
dragStateRef.current.moved = false;
};
strip.addEventListener('pointerdown', onPointerDown);
strip.addEventListener('pointermove', onPointerMove);
strip.addEventListener('pointerup', onPointerUpOrCancel);
strip.addEventListener('pointercancel', onPointerUpOrCancel);
strip.addEventListener('click', onClickCapture, true);
return () => {
strip.removeEventListener('pointerdown', onPointerDown);
strip.removeEventListener('pointermove', onPointerMove);
strip.removeEventListener('pointerup', onPointerUpOrCancel);
strip.removeEventListener('pointercancel', onPointerUpOrCancel);
strip.removeEventListener('click', onClickCapture, true);
};
}, [moveTo, offset]);
useEffect(() => () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = 0;
}
}, []);
const max = maxScroll;
const atStart = offset >= -2;
const atEnd = offset <= -(max - 2);
return (
<div className={`nb-react-carousel ${atStart ? 'at-start' : ''} ${atEnd ? 'at-end' : ''} ${className}`.trim()}>
<div className="nb-react-fade nb-react-fade--left" aria-hidden="true" />
<button
type="button"
className="nb-react-arrow nb-react-arrow--left"
aria-label="Previous categories"
onClick={() => moveToPill(-1)}
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-[18px] h-[18px]" aria-hidden="true"><path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd"/></svg>
</button>
<div className="nb-react-viewport" ref={viewportRef} role="list" aria-label={ariaLabel}>
<div
ref={stripRef}
className={`nb-react-strip ${dragging ? 'is-dragging' : ''}`}
style={{ transform: `translateX(${offset}px)` }}
>
{items.map((item) => (
<a
key={`${item.href}-${item.label}`}
href={item.href}
className={`nb-react-pill ${item.active ? 'nb-react-pill--active' : ''}`}
aria-current={item.active ? 'page' : 'false'}
data-active-pill={item.active ? 'true' : undefined}
draggable={false}
onDragStart={(event) => event.preventDefault()}
>
{item.label}
</a>
))}
</div>
</div>
<div className="nb-react-fade nb-react-fade--right" aria-hidden="true" />
<button
type="button"
className="nb-react-arrow nb-react-arrow--right"
aria-label="Next categories"
onClick={() => moveToPill(1)}
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-[18px] h-[18px]" aria-hidden="true"><path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd"/></svg>
</button>
</div>
);
}

View File

@@ -20,22 +20,20 @@
}
}
/* Spec §5: 4 columns desktop, scaling up for very wide screens */
@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; }
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@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; }
[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)); }
}
@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; }
@media (min-width: 2200px) {
[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].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
@@ -103,7 +101,7 @@
/* 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 {
[data-nova-gallery] [data-gallery-grid] .nova-card-media > .nova-card-main-image {
position: absolute;
inset: 0;
width: 100%;
@@ -136,3 +134,66 @@
transform: translateY(0);
transition: opacity 200ms ease-out, transform 200ms ease-out;
}
/* ── Card hover: bottom glow pulse ───────────────────────────────────────── */
.nova-card > a {
will-change: transform, box-shadow;
}
.nova-card:hover > a {
box-shadow:
0 8px 30px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.08),
0 0 20px rgba(224, 122, 33, 0.07);
}
/* ── Quick action buttons ─────────────────────────────────────────────────── */
/*
* .nb-card-actions absolutely positioned at top-right of .nova-card.
* Fades in + slides down slightly when the card is hovered.
* Requires .nova-card to have position:relative (set inline by ArtworkCard.jsx).
*/
.nb-card-actions {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 30;
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transform: translateY(-4px);
transition: opacity 200ms ease-out, transform 200ms ease-out;
pointer-events: none;
}
.nova-card:hover .nb-card-actions,
.nova-card:focus-within .nb-card-actions {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.nb-card-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: rgba(10, 14, 20, 0.75);
backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.85);
font-size: 0.875rem;
line-height: 1;
cursor: pointer;
text-decoration: none;
transition: background 150ms ease, transform 150ms ease, color 150ms ease;
-webkit-tap-highlight-color: transparent;
}
.nb-card-action-btn:hover {
background: rgba(224, 122, 33, 0.85);
color: #fff;
transform: scale(1.1);
}

View File

@@ -84,6 +84,54 @@ function SkeletonCard() {
return <div className="nova-skeleton-card" aria-hidden="true" />;
}
// ── Ranking API helpers ───────────────────────────────────────────────────
/**
* Map a single ArtworkListResource item (from /api/rank/*) to the internal
* artwork object shape used by ArtworkCard.
*/
function mapRankApiArtwork(item) {
const w = item.dimensions?.width ?? null;
const h = item.dimensions?.height ?? null;
const thumb = item.thumbnail_url ?? null;
const webUrl = item.urls?.web ?? item.category?.url ?? null;
return {
id: item.id ?? null,
name: item.title ?? item.name ?? null,
thumb: thumb,
thumb_url: thumb,
uname: item.author?.name ?? '',
username: item.author?.username ?? item.author?.name ?? '',
avatar_url: item.author?.avatar_url ?? null,
category_name: item.category?.name ?? '',
category_slug: item.category?.slug ?? '',
slug: item.slug ?? '',
url: webUrl,
width: w,
height: h,
};
}
/**
* Fetch ranked artworks from the ranking API.
* Returns { artworks: [...] } in internal shape, or { artworks: [] } on failure.
*/
async function fetchRankApiArtworks(endpoint, rankType) {
try {
const url = new URL(endpoint, window.location.href);
if (rankType) url.searchParams.set('type', rankType);
const res = await fetch(url.toString(), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok) return { artworks: [] };
const json = await res.json();
const items = Array.isArray(json.data) ? json.data : [];
return { artworks: items.map(mapRankApiArtwork) };
} catch {
return { artworks: [] };
}
}
const SKELETON_COUNT = 10;
// ── Main component ────────────────────────────────────────────────────────
@@ -97,6 +145,9 @@ const SKELETON_COUNT = 10;
* initialNextCursor string|null First cursor token
* initialNextPageUrl string|null First "next page" URL (page-based feeds)
* limit number Items per page (default 40)
* rankApiEndpoint string|null /api/rank/* endpoint; used as fallback data
* source when no SSR artworks are available
* rankType string|null Ranking API ?type= param (trending|new_hot|best)
*/
function MasonryGallery({
artworks: initialArtworks = [],
@@ -105,6 +156,8 @@ function MasonryGallery({
initialNextCursor = null,
initialNextPageUrl = null,
limit = 40,
rankApiEndpoint = null,
rankType = null,
}) {
const [artworks, setArtworks] = useState(initialArtworks);
const [nextCursor, setNextCursor] = useState(initialNextCursor);
@@ -115,6 +168,28 @@ function MasonryGallery({
const gridRef = useRef(null);
const triggerRef = useRef(null);
// ── Ranking API fallback ───────────────────────────────────────────────
// When the server-side render provides no initial artworks (e.g. cache miss
// or empty page result) and a ranking API endpoint is configured, perform a
// client-side fetch from the ranking API to hydrate the grid.
// Satisfies spec: "Fallback: Latest if ranking missing".
useEffect(() => {
if (initialArtworks.length > 0) return; // SSR artworks already present
if (!rankApiEndpoint) return; // no API endpoint configured
let cancelled = false;
setLoading(true);
fetchRankApiArtworks(rankApiEndpoint, rankType).then(({ artworks: ranked }) => {
if (cancelled) return;
if (ranked.length > 0) {
setArtworks(ranked);
setDone(true); // ranking API returns a full list; no further pagination
}
setLoading(false);
});
return () => { cancelled = true; };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Masonry re-layout ──────────────────────────────────────────────────
const relayout = useCallback(() => {
const g = gridRef.current;
@@ -195,6 +270,10 @@ function MasonryGallery({
return () => io.disconnect();
}, [done, fetchNext]);
// Gallery V2 spec §7: 5 col desktop / 3 tablet / 2 mobile for all gallery pages.
// Discover feeds (home/discover page) retain the same 5-col layout.
const gridClass = 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6';
// ── Render ─────────────────────────────────────────────────────────────
return (
<section
@@ -210,7 +289,7 @@ function MasonryGallery({
<>
<div
ref={gridRef}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 force-5"
className={gridClass}
data-gallery-grid
>
{artworks.map((art, idx) => (

View File

@@ -40,6 +40,8 @@ function mountAll() {
initialNextCursor: container.dataset.nextCursor || null,
initialNextPageUrl: container.dataset.nextPageUrl || null,
limit: parseInt(container.dataset.limit || '40', 10),
rankApiEndpoint: container.dataset.rankApiEndpoint || null,
rankType: container.dataset.rankType || null,
};
createRoot(container).render(<MasonryGallery {...props} />);

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import CategoryPillCarousel from './components/gallery/CategoryPillCarousel';
function mountAll() {
document.querySelectorAll('[data-react-pill-carousel]').forEach((container) => {
if (container.dataset.reactMounted) return;
container.dataset.reactMounted = '1';
let items = [];
try {
items = JSON.parse(container.dataset.items || '[]');
} catch {
items = [];
}
createRoot(container).render(
<CategoryPillCarousel
items={items}
ariaLabel={container.dataset.ariaLabel || 'Filter by category'}
className={container.dataset.className || ''}
/>,
);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountAll);
} else {
mountAll();
}

View File

@@ -158,13 +158,6 @@
/>
</picture>
<div class="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 class="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>
@if($authorUrl)
<span class="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>
@endif
</div>
<div class="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 class="truncate text-sm font-semibold text-white">{{ $title }}</div>
<div class="mt-1 flex items-center justify-between gap-3 text-xs text-white/80">

View File

@@ -0,0 +1,177 @@
{{--
Gallery Filter Slide-over Panel
────────────────────────────────────────────────────────────────────────────
Triggered by: #gallery-filter-panel-toggle (in gallery/index.blade.php)
Controlled by: initGalleryFilterPanel() (in gallery/index.blade.php scripts)
Available Blade variables (all optional, safe to omit):
$sort_options array Current sort options list
$current_sort string Active sort value
--}}
<div
id="gallery-filter-panel"
role="dialog"
aria-modal="true"
aria-label="Gallery filters"
aria-hidden="true"
class="fixed inset-0 z-50 pointer-events-none"
>
{{-- Backdrop --}}
<div
id="gallery-filter-backdrop"
class="absolute inset-0 bg-black/50 backdrop-blur-sm opacity-0 transition-opacity duration-300 ease-out"
aria-hidden="true"
></div>
{{-- Drawer --}}
<div
id="gallery-filter-drawer"
class="absolute right-0 top-0 bottom-0 w-full md:w-[22rem] bg-nova-800 border-l border-white/10 shadow-2xl
translate-x-full transition-transform duration-300 ease-out
flex flex-col overflow-hidden"
>
{{-- Header --}}
<div class="flex items-center justify-between px-5 py-4 border-b border-white/10 shrink-0">
<h2 class="text-base font-semibold text-white/90">Filters</h2>
<button
id="gallery-filter-panel-close"
type="button"
class="rounded-lg p-1.5 text-neutral-400 hover:text-white hover:bg-white/10 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
aria-label="Close filters"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{{-- Scrollable filter body --}}
<div class="flex-1 overflow-y-auto px-5 py-6 space-y-8">
{{-- ── Orientation ─────────────────────────────────────────────── --}}
<fieldset>
<legend class="text-[11px] font-semibold uppercase tracking-widest text-neutral-500 mb-3">Orientation</legend>
<div class="flex flex-wrap gap-2">
@foreach([['any','Any'],['landscape','Landscape 🖥'],['portrait','Portrait 📱']] as [$val, $label])
<label class="nb-filter-choice">
<input
type="radio"
name="orientation"
value="{{ $val }}"
class="sr-only"
{{ (request('orientation', 'any') === $val) ? 'checked' : '' }}
>
<span class="nb-filter-choice-label">{{ $label }}</span>
</label>
@endforeach
</div>
</fieldset>
{{-- ── Resolution ─────────────────────────────────────────────────── --}}
<fieldset>
<legend class="text-[11px] font-semibold uppercase tracking-widest text-neutral-500 mb-3">Resolution</legend>
<div class="flex flex-wrap gap-2">
@foreach([
['any', 'Any'],
['hd', 'HD 1280×720'],
['fhd', 'Full HD 1920×1080'],
['2k', '2K 2560×1440'],
['4k', '4K 3840×2160'],
] as [$val, $label])
<label class="nb-filter-choice">
<input
type="radio"
name="resolution"
value="{{ $val }}"
class="sr-only"
{{ (request('resolution', 'any') === $val) ? 'checked' : '' }}
>
<span class="nb-filter-choice-label">{{ $label }}</span>
</label>
@endforeach
</div>
</fieldset>
{{-- ── Date Range ───────────────────────────────────────────────── --}}
<fieldset>
<legend class="text-[11px] font-semibold uppercase tracking-widest text-neutral-500 mb-3">Date Range</legend>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-neutral-400 mb-1.5" for="fp-date-from">From</label>
<input
type="date"
id="fp-date-from"
name="date_from"
value="{{ request('date_from') }}"
max="{{ date('Y-m-d') }}"
class="nb-filter-input w-full"
/>
</div>
<div>
<label class="block text-xs text-neutral-400 mb-1.5" for="fp-date-to">To</label>
<input
type="date"
id="fp-date-to"
name="date_to"
value="{{ request('date_to') }}"
max="{{ date('Y-m-d') }}"
class="nb-filter-input w-full"
/>
</div>
</div>
</fieldset>
{{-- ── Author ──────────────────────────────────────────────────── --}}
<fieldset>
<legend class="text-[11px] font-semibold uppercase tracking-widest text-neutral-500 mb-3">Author</legend>
<input
type="text"
id="fp-author"
name="author"
value="{{ request('author') }}"
placeholder="Username or display name"
autocomplete="off"
class="nb-filter-input w-full"
/>
</fieldset>
{{-- ── Sort ─────────────────────────────────────────────────────── --}}
@if(!empty($sort_options))
<fieldset>
<legend class="text-[11px] font-semibold uppercase tracking-widest text-neutral-500 mb-3">Sort By</legend>
<div class="flex flex-col gap-2">
@foreach($sort_options as $opt)
<label class="nb-filter-choice nb-filter-choice--block">
<input
type="radio"
name="sort"
value="{{ $opt['value'] }}"
class="sr-only"
{{ ($current_sort ?? 'trending') === $opt['value'] ? 'checked' : '' }}
>
<span class="nb-filter-choice-label w-full text-left">{{ $opt['label'] }}</span>
</label>
@endforeach
</div>
</fieldset>
@endif
</div>
{{-- Footer actions --}}
<div class="shrink-0 flex items-center gap-3 px-5 py-4 border-t border-white/10 bg-nova-900/40">
<button
id="gallery-filter-reset"
type="button"
class="flex-1 rounded-lg border border-white/10 bg-white/5 py-2.5 text-sm text-neutral-300 hover:text-white hover:bg-white/10 transition-colors"
>
Reset
</button>
<button
id="gallery-filter-apply"
type="button"
class="flex-1 rounded-lg bg-accent py-2.5 text-sm font-semibold text-white shadow-sm shadow-accent/30 hover:bg-amber-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
>
Apply Filters
</button>
</div>
</div>
</div>

View File

@@ -2,7 +2,6 @@
@php
use App\Banner;
$gridV2 = request()->query('grid') === 'v2';
@endphp
@php
@@ -22,125 +21,257 @@
@if($seoPrev)<link rel="prev" href="{{ $seoPrev }}">@endif
@if($seoNext)<link rel="next" href="{{ $seoNext }}">@endif
<meta name="robots" content="index,follow">
{{-- OpenGraph --}}
<meta property="og:type" content="website" />
<meta property="og:url" content="{{ $page_canonical ?? $seoUrl(1) }}" />
<meta property="og:title" content="{{ $page_title ?? ($hero_title ?? 'Skinbase') }}" />
<meta property="og:description" content="{{ $page_meta_description ?? '' }}" />
<meta property="og:site_name" content="Skinbase" />
{{-- Twitter card --}}
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{ $page_title ?? ($hero_title ?? 'Skinbase') }}" />
<meta name="twitter:description" content="{{ $page_meta_description ?? '' }}" />
@endpush
@php
// ── Rank API endpoint ────────────────────────────────────────────────────
// Map the active sort alias to the ranking API ?type= parameter.
// Only trending / fresh / top-rated have pre-computed ranking lists.
$rankTypeMap = [
'trending' => 'trending',
'fresh' => 'new_hot',
'top-rated' => 'best',
];
$rankApiType = $rankTypeMap[$current_sort ?? 'trending'] ?? null;
$rankApiEndpoint = null;
if ($rankApiType) {
if (isset($category) && $category && $category->id ?? null) {
$rankApiEndpoint = '/api/rank/category/' . $category->id;
} elseif (isset($contentType) && $contentType && $contentType->slug ?? null) {
$rankApiEndpoint = '/api/rank/type/' . $contentType->slug;
} else {
$rankApiEndpoint = '/api/rank/global';
}
}
@endphp
@section('content')
<div class="container-fluid legacy-page">
@php Banner::ShowResponsiveAd(); @endphp
<div class="pt-0">
<div class="mx-auto w-full">
<div class="relative flex min-h-[calc(100vh-64px)]">
<div class="relative min-h-[calc(100vh-64px)]">
<button
id="sidebar-toggle"
type="button"
class="hidden md:inline-flex items-center justify-center h-10 w-10 rounded-lg border border-white/10 bg-white/5 text-white/90 hover:bg-white/10 absolute top-3 z-20"
aria-controls="sidebar"
aria-expanded="true"
aria-label="Toggle sidebar"
style="left:16px;"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<main class="w-full">
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nova-900/60 backdrop-blur-sm">
<div class="p-4">
<div class="mt-2 text-sm text-neutral-400">
<div class="font-semibold text-white/80 mb-2">Main Categories:</div>
<ul class="space-y-2">
@foreach($mainCategories as $main)
<li>
<a class="flex items-center gap-2 hover:text-white" href="{{ $main->url }}"><span class="opacity-70">📁</span> {{ $main->name }}</a>
</li>
@endforeach
</ul>
<div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div>
<ul class="space-y-2 pr-2">
@forelse($subcategories as $sub)
@php
$subName = $sub->category_name ?? $sub->name ?? null;
$subUrl = $sub->url ?? ((isset($sub->slug) && isset($contentType)) ? '/' . $contentType->slug . '/' . $sub->slug : null);
$isActive = isset($category) && isset($sub->id) && $category && ((int) $sub->id === (int) $category->id);
@endphp
<li>
@if($subUrl)
<a class="hover:text-white {{ $isActive ? 'font-semibold text-white' : 'text-neutral-400' }}" href="{{ $subUrl }}">{{ $subName }}</a>
@else
<span class="text-neutral-400">{{ $subName }}</span>
@endif
</li>
@empty
<li><span class="text-neutral-500">No subcategories</span></li>
@endforelse
</ul>
</div>
</div>
</aside>
<main class="flex-1">
{{-- ═══════════════════════════════════════════════════════════════ --}}
{{-- HERO HEADER --}}
{{-- ═══════════════════════════════════════════════════════════════ --}}
<div class="relative overflow-hidden nb-hero-radial">
<div class="absolute inset-0 opacity-35"></div>
{{-- Animated gradient overlays --}}
<div class="absolute inset-0 nb-hero-gradient" aria-hidden="true"></div>
<div class="absolute inset-0 opacity-20 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,#E07A2130,transparent)]" aria-hidden="true"></div>
<div class="relative px-6 py-8 md:px-10 md:py-10">
<div class="text-sm text-neutral-400">
@if(($gallery_type ?? null) === 'browse')
Browse
@elseif(isset($contentType) && $contentType)
<a class="hover:text-white" href="/{{ $contentType->slug }}">{{ $contentType->name }}</a>
@if(($gallery_type ?? null) === 'category')
@foreach($breadcrumbs as $crumb)
<span class="opacity-50"></span>
<a class="hover:text-white" href="{{ $crumb->url }}">{{ $crumb->name }}</a>
@endforeach
@endif
<div class="relative px-6 py-10 md:px-10 md:py-14">
{{-- Breadcrumb --}}
<nav class="flex items-center gap-1.5 flex-wrap text-sm text-neutral-400" aria-label="Breadcrumb">
<a class="hover:text-white transition-colors" href="/browse">Gallery</a>
@if(isset($contentType) && $contentType)
<span class="opacity-40" aria-hidden="true"></span>
<a class="hover:text-white transition-colors" href="/{{ $contentType->slug }}">{{ $contentType->name }}</a>
@endif
@if(($gallery_type ?? null) === 'category')
@foreach($breadcrumbs as $crumb)
<span class="opacity-40" aria-hidden="true"></span>
<a class="hover:text-white transition-colors" href="{{ $crumb->url }}">{{ $crumb->name }}</a>
@endforeach
@endif
</nav>
{{-- Glass title panel --}}
<div class="mt-4 py-5">
<h1 class="text-3xl md:text-4xl font-bold tracking-tight text-white/95 leading-tight">
{{ $hero_title ?? 'Browse Artworks' }}
</h1>
@if(!empty($hero_description))
<p class="mt-2 text-sm leading-6 text-neutral-400 max-w-xl">
{!! $hero_description !!}
</p>
@endif
@if(is_object($artworks) && method_exists($artworks, 'total') && $artworks->total() > 0)
<div class="mt-3 flex items-center gap-1.5 text-xs text-neutral-500">
<svg class="h-3.5 w-3.5 text-accent/70" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{{ number_format($artworks->total()) }} artworks</span>
</div>
@endif
</div>
<h1 class="mt-2 text-3xl md:text-4xl font-semibold tracking-tight text-white/95">{{ $hero_title ?? 'Browse Artworks' }}</h1>
</div>
<section class="mt-5 bg-white/5 border border-white/10 rounded-2xl shadow-lg">
<div class="p-5 md:p-6">
<div class="text-lg font-semibold text-white/90">{{ $hero_title ?? 'Browse Artworks' }}</div>
<p class="mt-2 text-sm leading-6 text-neutral-400">{!! $hero_description ?? '' !!}</p>
</div>
</section>
<div class="absolute left-0 right-0 bottom-0 h-16 nb-hero-fade pointer-events-none" aria-hidden="true"></div>
</div>
<div class="absolute left-0 right-0 bottom-0 h-36 nb-hero-fade pointer-events-none" aria-hidden="true"></div>
{{-- ═══════════════════════════════════════════════════════════════ --}}
{{-- RANKING TABS --}}
{{-- ═══════════════════════════════════════════════════════════════ --}}
@php
$rankingTabs = [
['value' => 'trending', 'label' => 'Trending', 'icon' => '🔥'],
['value' => 'fresh', 'label' => 'New & Hot', 'icon' => '🚀'],
['value' => 'top-rated', 'label' => 'Best', 'icon' => '⭐'],
['value' => 'latest', 'label' => 'Latest', 'icon' => '🕐'],
];
$activeTab = $current_sort ?? 'trending';
@endphp
<div class="sticky top-0 z-30 border-b border-white/10 bg-nova-900/90 backdrop-blur-md" id="gallery-ranking-tabs">
<div class="px-6 md:px-10">
<div class="flex items-center justify-between gap-4">
{{-- Tab list --}}
<nav class="flex items-center gap-0 -mb-px nb-scrollbar-none overflow-x-auto" role="tablist" aria-label="Gallery ranking">
@foreach($rankingTabs as $tab)
@php $isActive = $activeTab === $tab['value']; @endphp
<button
role="tab"
aria-selected="{{ $isActive ? 'true' : 'false' }}"
data-rank-tab="{{ $tab['value'] }}"
class="gallery-rank-tab relative flex items-center gap-1.5 whitespace-nowrap px-5 py-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 {{ $isActive ? 'text-white' : 'text-neutral-400 hover:text-white' }}"
>
<span aria-hidden="true">{{ $tab['icon'] }}</span>
{{ $tab['label'] }}
{{-- Active underline indicator --}}
<span class="nb-tab-indicator absolute bottom-0 left-0 right-0 h-0.5 {{ $isActive ? 'bg-accent scale-x-100' : 'bg-transparent scale-x-0' }} transition-transform duration-300 origin-left rounded-full"></span>
</button>
@endforeach
</nav>
{{-- Filters button wired to slide-over panel (Phase 3) --}}
<button
id="gallery-filter-panel-toggle"
type="button"
class="hidden md:flex items-center gap-2 shrink-0 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/80 hover:bg-white/10 hover:text-white transition-colors"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="gallery-filter-panel"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z" />
</svg>
Filters
</button>
</div>
</div>
</div>
<section class="px-6 pb-10 pt-8 md:px-10" data-nova-gallery data-gallery-type="{{ $gallery_type ?? 'browse' }}">
<div class="{{ $gridV2 ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 force-5' }}" data-gallery-grid>
@forelse ($artworks as $art)
<x-artwork-card
:art="$art"
:loading="$loop->index < 8 ? 'eager' : 'lazy'"
:fetchpriority="$loop->index === 0 ? 'high' : null"
/>
@empty
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>No Artworks Yet</strong></div>
<div class="panel-body">
<p>Once uploads arrive they will appear here. Check back soon.</p>
</div>
</div>
@endforelse
</div>
{{-- ═══════════════════════════════════════════════════════════════ --}}
{{-- HORIZONTAL CATEGORY FILTER ROW --}}
{{-- ═══════════════════════════════════════════════════════════════ --}}
@php
$filterItems = $subcategories ?? collect();
$activeFilterId = isset($category) ? ($category->id ?? null) : null;
$categoryAllHref = isset($contentType) && $contentType
? url('/' . $contentType->slug)
: url('/browse');
$activeSortSlug = $activeTab !== 'trending' ? $activeTab : null;
@endphp
<div class="flex justify-center mt-10" data-gallery-pagination>
@if ($artworks instanceof \Illuminate\Contracts\Pagination\Paginator || $artworks instanceof \Illuminate\Contracts\Pagination\CursorPaginator)
{{ method_exists($artworks, 'withQueryString') ? $artworks->withQueryString()->links() : $artworks->links() }}
@endif
</div>
<div class="hidden" data-gallery-skeleton-template aria-hidden="true">
<x-skeleton.artwork-card />
</div>
<div class="hidden mt-8" data-gallery-skeleton></div>
@if($filterItems->isNotEmpty())
<div class="sticky top-[57px] z-20 bg-nova-900/80 backdrop-blur-md border-b border-white/[0.06]">
@php
$allHref = $categoryAllHref . ($activeSortSlug ? '?sort=' . $activeSortSlug : '');
$carouselItems = [[
'label' => 'All',
'href' => $allHref,
'active' => !$activeFilterId,
]];
foreach ($filterItems as $sub) {
$subName = $sub->name ?? $sub->category_name ?? null;
$subUrl = $sub->url ?? null;
if (! $subUrl && isset($sub->slug) && isset($contentType) && $contentType) {
$subUrl = url('/' . $contentType->slug . '/' . $sub->slug);
}
if (! $subName || ! $subUrl) {
continue;
}
$sep = str_contains($subUrl, '?') ? '&' : '?';
$subLinkHref = $activeSortSlug ? ($subUrl . $sep . 'sort=' . $activeSortSlug) : $subUrl;
$isActiveSub = $activeFilterId && isset($sub->id) && (int) $sub->id === (int) $activeFilterId;
$carouselItems[] = [
'label' => $subName,
'href' => $subLinkHref,
'active' => $isActiveSub,
];
}
@endphp
<div
data-react-pill-carousel
data-aria-label="Filter by category"
data-items='@json($carouselItems)'
></div>
</div>
@endif
@php
$galleryItems = (is_object($artworks) && method_exists($artworks, 'getCollection'))
? $artworks->getCollection()
: collect($artworks);
$galleryArtworks = $galleryItems->map(fn ($art) => [
'id' => $art->id ?? null,
'name' => $art->name ?? null,
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->username ?? $art->uname ?? '',
'avatar_url' => $art->avatar_url ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',
'width' => $art->width ?? null,
'height' => $art->height ?? null,
])->values();
$galleryNextPageUrl = (is_object($artworks) && method_exists($artworks, 'nextPageUrl'))
? $artworks->nextPageUrl()
: null;
@endphp
<section class="px-6 pb-10 pt-8 md:px-10">
@if($galleryItems->isEmpty())
<div class="rounded-xl border border-white/10 bg-white/5 p-8 text-center text-white/60">
No artworks found yet. Check back soon.
</div>
@else
<div
data-react-masonry-gallery
data-artworks="{{ json_encode($galleryArtworks) }}"
data-gallery-type="{{ $gallery_type ?? 'browse' }}"
@if($galleryNextPageUrl) data-next-page-url="{{ $galleryNextPageUrl }}" @endif
@if($rankApiEndpoint) data-rank-api-endpoint="{{ $rankApiEndpoint }}" @endif
@if($rankApiType) data-rank-type="{{ $rankApiType }}" @endif
data-limit="24"
class="min-h-32"
></div>
@endif
</section>
{{-- ─── Filter Slide-over Panel ──────────────────────────────────── --}}
@include('gallery._filter_panel')
</main>
</div>
</div>
@@ -148,155 +279,241 @@
</div>
@endsection
@push('styles')
@if(! $gridV2)
@push('head')
<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) {
/* Fallback for non-enhanced (no-js) galleries: use 5 columns on desktop */
[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)); }
/* High-specificity override for legacy/tailwind classes */
[data-gallery-grid].force-5 { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; }
}
/* Larger desktop screens: 6 columns */
@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; }
}
/* Ensure dashboard gallery shows 5 columns on desktop even when JS hasn't enhanced */
[data-nova-gallery][data-gallery-type="dashboard"] [data-gallery-grid] {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
@media (min-width: 1600px) {
[data-nova-gallery][data-gallery-type="dashboard"] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
/* Keep pagination visible when JS enhances the gallery so users
have a clear navigation control (numeric links for length-aware
paginators, prev/next for cursor paginators). Make it compact. */
[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%; }
}
/* ── Hero ─────────────────────────────────────────────────────── */
.nb-hero-fade {
background: linear-gradient(180deg, rgba(17,24,39,0) 0%, rgba(7,10,15,0.9) 60%, rgba(7,10,15,1) 100%);
}
.nb-hero-gradient {
background: linear-gradient(135deg, rgba(224,122,33,0.08) 0%, rgba(15,23,36,0) 50%, rgba(21,36,58,0.4) 100%);
animation: nb-hero-shimmer 8s ease-in-out infinite alternate;
}
@keyframes nb-hero-shimmer {
0% { opacity: 0.6; }
100% { opacity: 1; }
}
/* ── Ranking Tabs ─────────────────────────────────────────────── */
.gallery-rank-tab {
-webkit-tap-highlight-color: transparent;
}
.gallery-rank-tab .nb-tab-indicator {
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1), background-color 200ms ease;
}
/* Legacy: keep nb-scrollbar-none working elsewhere in the page */
.nb-scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
.nb-scrollbar-none::-webkit-scrollbar { display: none; }
/* ── Gallery grid fade-in on page load / tab change ─────────── */
@keyframes nb-gallery-fade-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
[data-react-masonry-gallery] {
animation: nb-gallery-fade-in 300ms ease-out both;
}
/* ── Filter panel choice pills ───────────────────────────────── */
.nb-filter-choice { display: inline-flex; cursor: pointer; }
.nb-filter-choice--block { display: flex; width: 100%; }
.nb-filter-choice-label {
display: inline-flex;
align-items: center;
padding: 0.375rem 0.875rem;
border-radius: 9999px;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.05);
color: rgba(214,224,238,0.8);
font-size: 0.8125rem;
font-weight: 500;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
white-space: nowrap;
}
.nb-filter-choice--block .nb-filter-choice-label {
border-radius: 0.6rem;
width: 100%;
}
.nb-filter-choice input:checked ~ .nb-filter-choice-label {
background: #E07A21;
border-color: #E07A21;
color: #fff;
box-shadow: 0 1px 8px rgba(224,122,33,0.35);
}
.nb-filter-choice input:focus-visible ~ .nb-filter-choice-label {
outline: 2px solid rgba(224,122,33,0.6);
outline-offset: 2px;
}
/* Filter date/text inputs */
.nb-filter-input {
appearance: none;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 0.5rem;
color: rgba(255,255,255,0.85);
font-size: 0.8125rem;
padding: 0.425rem 0.75rem;
transition: border-color 150ms ease;
color-scheme: dark;
}
.nb-filter-input:focus {
outline: none;
border-color: rgba(224,122,33,0.6);
box-shadow: 0 0 0 3px rgba(224,122,33,0.15);
}
</style>
@endif
@endpush
@push('scripts')
@vite('resources/js/entry-masonry-gallery.jsx')
@vite('resources/js/entry-pill-carousel.jsx')
<script src="/js/legacy-gallery-init.js" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var toggle = document.getElementById('sidebar-toggle');
var sidebar = document.getElementById('sidebar');
if (!toggle || !sidebar) return;
(function () {
'use strict';
var collapsed = false;
try {
collapsed = window.localStorage.getItem('gallery.sidebar.collapsed') === '1';
} catch (e) {
collapsed = false;
// ── Filter Slide-over Panel ──────────────────────────────────────────
function initGalleryFilterPanel() {
var panel = document.getElementById('gallery-filter-panel');
var backdrop = document.getElementById('gallery-filter-backdrop');
var drawer = document.getElementById('gallery-filter-drawer');
var toggleBtn = document.getElementById('gallery-filter-panel-toggle');
var closeBtn = document.getElementById('gallery-filter-panel-close');
var applyBtn = document.getElementById('gallery-filter-apply');
var resetBtn = document.getElementById('gallery-filter-reset');
if (!panel || !drawer || !backdrop) return;
var isOpen = false;
function openPanel() {
isOpen = true;
panel.setAttribute('aria-hidden', 'false');
panel.classList.remove('pointer-events-none');
panel.classList.add('pointer-events-auto');
backdrop.classList.remove('opacity-0');
backdrop.classList.add('opacity-100');
drawer.classList.remove('translate-x-full');
drawer.classList.add('translate-x-0');
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'true');
// Focus first interactive element in drawer
var first = drawer.querySelector('button, input, select, a[href]');
if (first) { setTimeout(function () { if (first) first.focus(); }, 320); }
}
function applySidebarState() {
if (collapsed) {
sidebar.classList.add('md:hidden');
toggle.setAttribute('aria-expanded', 'false');
} else {
sidebar.classList.remove('md:hidden');
toggle.setAttribute('aria-expanded', 'true');
}
positionToggle();
function closePanel() {
isOpen = false;
panel.setAttribute('aria-hidden', 'true');
panel.classList.add('pointer-events-none');
panel.classList.remove('pointer-events-auto');
backdrop.classList.add('opacity-0');
backdrop.classList.remove('opacity-100');
drawer.classList.add('translate-x-full');
drawer.classList.remove('translate-x-0');
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'false');
}
toggle.addEventListener('click', function () {
collapsed = !collapsed;
applySidebarState();
try {
window.localStorage.setItem('gallery.sidebar.collapsed', collapsed ? '1' : '0');
} catch (e) {
// no-op
}
if (toggleBtn) toggleBtn.addEventListener('click', function () { isOpen ? closePanel() : openPanel(); });
if (closeBtn) closeBtn.addEventListener('click', closePanel);
backdrop.addEventListener('click', closePanel);
// Close on ESC
document.addEventListener('keydown', function (e) {
if (isOpen && (e.key === 'Escape' || e.key === 'Esc')) { closePanel(); }
});
function positionToggle() {
if (!toggle || !sidebar) return;
// when sidebar is visible, position toggle just outside its right edge
if (!collapsed) {
var rect = sidebar.getBoundingClientRect();
if (rect && rect.right) {
toggle.style.left = (rect.right + 8) + 'px';
toggle.style.transform = '';
} else {
// fallback to sidebar width (18rem)
toggle.style.left = 'calc(18rem + 8px)';
}
} else {
// when collapsed, position toggle near page left edge
toggle.style.left = '16px';
toggle.style.transform = '';
}
// Apply: collect all named inputs and navigate with updated params
if (applyBtn) {
applyBtn.addEventListener('click', function () {
var url = new URL(window.location.href);
url.searchParams.delete('page');
// Radio groups: orientation, resolution, sort
drawer.querySelectorAll('input[type="radio"]:checked').forEach(function (input) {
if ((input.name === 'orientation' || input.name === 'resolution') && input.value !== 'any') {
url.searchParams.set(input.name, input.value);
} else if (input.name === 'orientation' || input.name === 'resolution') {
url.searchParams.delete(input.name);
} else {
url.searchParams.set(input.name, input.value);
}
});
// Text inputs: author
['date_from', 'date_to', 'author'].forEach(function (name) {
var el = drawer.querySelector('[name="' + name + '"]');
if (el && el.value) {
url.searchParams.set(name, el.value);
} else {
url.searchParams.delete(name);
}
});
window.location.href = url.toString();
});
}
window.addEventListener('resize', function () { positionToggle(); });
// Reset: strip all filter params, keep only current path
if (resetBtn) {
resetBtn.addEventListener('click', function () {
var url = new URL(window.location.href);
['orientation', 'resolution', 'author', 'date_from', 'date_to', 'sort', 'page'].forEach(function (p) {
url.searchParams.delete(p);
});
window.location.href = url.toString();
});
}
}
applySidebarState();
// ensure initial position set
positionToggle();
});
// ── Ranking Tab navigation ───────────────────────────────────────────
// Clicking a tab updates ?sort= in the URL and navigates.
// Active underline animation plays before navigation for visual feedback.
function initRankingTabs() {
var tabBar = document.getElementById('gallery-ranking-tabs');
if (!tabBar) return;
tabBar.addEventListener('click', function (e) {
var btn = e.target.closest('[data-rank-tab]');
if (!btn) return;
var sortValue = btn.dataset.rankTab;
if (!sortValue) return;
// Optimistic visual feedback — light up the clicked tab
tabBar.querySelectorAll('[data-rank-tab]').forEach(function (t) {
var ind = t.querySelector('.nb-tab-indicator');
if (t === btn) {
t.classList.add('text-white');
t.classList.remove('text-neutral-400');
if (ind) { ind.classList.add('bg-accent', 'scale-x-100'); ind.classList.remove('bg-transparent', 'scale-x-0'); }
} else {
t.classList.remove('text-white');
t.classList.add('text-neutral-400');
if (ind) { ind.classList.remove('bg-accent', 'scale-x-100'); ind.classList.add('bg-transparent', 'scale-x-0'); }
}
});
// Navigate to the new URL
var url = new URL(window.location.href);
url.searchParams.set('sort', sortValue);
url.searchParams.delete('page');
window.location.href = url.toString();
});
}
function init() {
initGalleryFilterPanel();
initRankingTabs();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());
</script>
@endpush

View File

@@ -49,6 +49,7 @@
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->uname ?? '',
'avatar_url' => $art->avatar_url ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',

View File

@@ -56,6 +56,7 @@
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->uname ?? '',
'avatar_url' => $art->avatar_url ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',