Files
SkinbaseNova/resources/views/gallery/index.blade.php

520 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@extends('layouts.nova')
@php
use App\Banner;
@endphp
@php
$seoPage = max(1, (int) request()->query('page', 1));
$seoBase = url()->current();
$seoQ = request()->query(); unset($seoQ['page']);
$seoUrl = fn(int $p) => $seoBase . ($p > 1
? '?' . http_build_query(array_merge($seoQ, ['page' => $p]))
: (count($seoQ) ? '?' . http_build_query($seoQ) : ''));
$seoPrev = $seoPage > 1 ? $seoUrl($seoPage - 1) : null;
$seoNext = (isset($artworks) && method_exists($artworks, 'nextPageUrl'))
? $artworks->nextPageUrl() : null;
@endphp
@push('head')
<link rel="canonical" href="{{ $seoUrl($seoPage) }}">
@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 min-h-[calc(100vh-64px)]">
<main class="w-full">
{{-- ═══════════════════════════════════════════════════════════════ --}}
{{-- HERO HEADER --}}
{{-- ═══════════════════════════════════════════════════════════════ --}}
<div class="relative overflow-hidden nb-hero-radial">
{{-- 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-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>
</div>
<div class="absolute left-0 right-0 bottom-0 h-16 nb-hero-fade pointer-events-none" aria-hidden="true"></div>
</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>
{{-- ═══════════════════════════════════════════════════════════════ --}}
{{-- 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
@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>
</div>
</div>
@endsection
@push('head')
<style>
/* ── 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>
@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>
(function () {
'use strict';
// ── 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 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');
}
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(); }
});
// 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();
});
}
// 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();
});
}
}
// ── 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