feat: add reusable gallery carousel and ranking feed infrastructure
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
158
resources/js/components/gallery/CategoryPillCarousel.css
Normal file
158
resources/js/components/gallery/CategoryPillCarousel.css
Normal 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;
|
||||
}
|
||||
282
resources/js/components/gallery/CategoryPillCarousel.jsx
Normal file
282
resources/js/components/gallery/CategoryPillCarousel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
31
resources/js/entry-pill-carousel.jsx
Normal file
31
resources/js/entry-pill-carousel.jsx
Normal 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();
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
177
resources/views/gallery/_filter_panel.blade.php
Normal file
177
resources/views/gallery/_filter_panel.blade.php
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
Reference in New Issue
Block a user