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

@@ -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 ?? '',