Save workspace changes
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
{{--
|
||||
Browse section-switcher pills.
|
||||
|
||||
Expected variable: $section (string) — one of: artworks, photography, wallpapers, skins, other
|
||||
--}}
|
||||
|
||||
@php
|
||||
$active = $section ?? 'artworks';
|
||||
$includeTags = (bool) ($includeTags ?? false);
|
||||
$contentTypes = collect($contentTypes ?? $mainCategories ?? []);
|
||||
$iconMap = [
|
||||
'photography' => 'fa-camera',
|
||||
'wallpapers' => 'fa-desktop',
|
||||
'skins' => 'fa-layer-group',
|
||||
'digital-art' => 'fa-palette',
|
||||
'other' => 'fa-folder-open',
|
||||
];
|
||||
|
||||
$sections = collect([
|
||||
'artworks' => ['label' => 'All Artworks', 'icon' => 'fa-border-all', 'href' => '/browse'],
|
||||
])->merge(
|
||||
$contentTypes->mapWithKeys(function ($type) use ($iconMap) {
|
||||
$slug = strtolower((string) ($type->slug ?? ''));
|
||||
|
||||
return [$slug => [
|
||||
'label' => $type->name,
|
||||
'icon' => $iconMap[$slug] ?? 'fa-folder-open',
|
||||
'href' => $type->url ?? ('/' . $slug),
|
||||
]];
|
||||
})
|
||||
);
|
||||
|
||||
if ($includeTags) {
|
||||
$sections->put('tags', ['label' => 'Tags', 'icon' => 'fa-tags', 'href' => '/tags']);
|
||||
}
|
||||
@endphp
|
||||
|
||||
<nav class="flex flex-wrap items-center gap-2 text-sm" aria-label="Browse sections">
|
||||
@foreach($sections as $slug => $meta)
|
||||
<a href="{{ $meta['href'] }}"
|
||||
@if($active === $slug) aria-current="page" @endif
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
{{ $active === $slug
|
||||
? 'bg-sky-600 text-white'
|
||||
: 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white' }}">
|
||||
<i class="fa-solid {{ $meta['icon'] }} text-xs"></i>
|
||||
{{ $meta['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,521 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
use App\Banner;
|
||||
$useUnifiedSeo = true;
|
||||
@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
|
||||
|
||||
@php
|
||||
$page_canonical = $page_canonical ?? $seoUrl($seoPage);
|
||||
$page_rel_prev = $page_rel_prev ?? $seoPrev;
|
||||
$page_rel_next = $page_rel_next ?? $seoNext;
|
||||
$page_robots = $page_robots ?? 'index,follow';
|
||||
@endphp
|
||||
|
||||
@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';
|
||||
}
|
||||
}
|
||||
|
||||
$tagContext = ($gallery_type ?? null) === 'tag' ? ($tag_context ?? null) : null;
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
@php Banner::ShowResponsiveAd(); @endphp
|
||||
|
||||
@php
|
||||
$browseSection = $gallery_nav_section
|
||||
?? (isset($contentType) && $contentType ? strtolower((string) $contentType->slug) : (($gallery_type ?? null) === 'tag' ? 'tags' : 'artworks'));
|
||||
$browseIconMap = [
|
||||
'artworks' => 'fa-border-all',
|
||||
'photography' => 'fa-camera',
|
||||
'wallpapers' => 'fa-desktop',
|
||||
'skins' => 'fa-layer-group',
|
||||
'digital-art' => 'fa-palette',
|
||||
'other' => 'fa-folder-open',
|
||||
'tags' => 'fa-tags',
|
||||
];
|
||||
$browseIcon = $browseIconMap[$browseSection] ?? 'fa-border-all';
|
||||
@endphp
|
||||
|
||||
<div class="pt-0">
|
||||
<div class="mx-auto w-full">
|
||||
<div class="relative min-h-[calc(120vh-64px)] md:min-h-[calc(100vh-64px)]">
|
||||
|
||||
<main class="w-full">
|
||||
|
||||
{{-- ── Hero header (discover-style) ── --}}
|
||||
@php
|
||||
$headerBreadcrumbs = collect();
|
||||
|
||||
if (($gallery_type ?? null) === 'browse') {
|
||||
$headerBreadcrumbs = $breadcrumbs ?? collect();
|
||||
} elseif (isset($breadcrumbs) && $breadcrumbs->isNotEmpty()) {
|
||||
$headerBreadcrumbs = $breadcrumbs;
|
||||
} elseif (isset($contentType) && $contentType) {
|
||||
$headerBreadcrumbs = collect([
|
||||
(object) ['name' => 'Explore', 'url' => '/browse'],
|
||||
(object) ['name' => $contentType->name, 'url' => '/' . strtolower($contentType->slug)],
|
||||
]);
|
||||
}
|
||||
@endphp
|
||||
|
||||
<x-nova-page-header
|
||||
section="{{ ($gallery_type ?? null) === 'tag' ? 'Tags' : 'Browse' }}"
|
||||
:title="$hero_title ?? 'Browse Artworks'"
|
||||
:icon="$browseIcon"
|
||||
:breadcrumbs="$headerBreadcrumbs"
|
||||
:description="$hero_description ?? null"
|
||||
actionsClass="lg:pt-8"
|
||||
>
|
||||
<x-slot name="actions">
|
||||
@include('gallery._browse_nav', ['section' => $browseSection, 'includeTags' => ($gallery_type ?? null) === 'tag'])
|
||||
</x-slot>
|
||||
</x-nova-page-header>
|
||||
|
||||
@if($tagContext)
|
||||
<section class="px-6 pt-8 md:px-10">
|
||||
@php
|
||||
$topCompanionTag = collect($tagContext['related_tags'] ?? [])->first();
|
||||
@endphp
|
||||
<div class="overflow-hidden rounded-[1.75rem] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_34%),linear-gradient(135deg,rgba(11,17,27,0.96),rgba(10,16,24,0.88))] shadow-[0_20px_70px_rgba(3,7,18,0.24)]">
|
||||
<div class="grid gap-6 p-6 md:p-7 xl:grid-cols-[minmax(0,1.35fr)_minmax(300px,0.85fr)] xl:items-start">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-white/56">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200">
|
||||
<span class="h-2 w-2 rounded-full bg-sky-300"></span>
|
||||
Tag feed
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-medium text-white/60">
|
||||
Sorted by {{ $tagContext['current_sort_label'] ?? 'Most viewed' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-2xl font-semibold tracking-tight text-white md:text-3xl">
|
||||
#{{ $tagContext['slug'] ?? $tagContext['name'] }}
|
||||
</h2>
|
||||
<p class="max-w-3xl text-sm leading-6 text-white/62 md:text-base">
|
||||
A focused feed for artwork tied to this theme. Use the ranking tabs to switch between momentum, recency, and quality without leaving the tag context.
|
||||
</p>
|
||||
@if($topCompanionTag && isset($topCompanionTag->shared_artworks_count))
|
||||
<a href="{{ route('tags.show', $topCompanionTag->slug) }}" data-tag-analytics-link data-tag-analytics-surface="top_companion" data-tag-analytics-tag="{{ $topCompanionTag->slug }}" data-tag-analytics-source-tag="{{ $tagContext['slug'] ?? '' }}" data-tag-analytics-position="1" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3.5 py-2 text-sm text-white/68 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08] hover:text-white">
|
||||
<span class="text-sky-200">Top companion</span>
|
||||
<span class="font-medium text-white">#{{ $topCompanionTag->name }}</span>
|
||||
<span class="text-white/34">{{ number_format($topCompanionTag->shared_artworks_count) }} shared artworks</span>
|
||||
@if(isset($topCompanionTag->transition_clicks) && (int) $topCompanionTag->transition_clicks > 0)
|
||||
<span class="text-emerald-200">{{ number_format((int) $topCompanionTag->transition_clicks) }} recent clicks</span>
|
||||
@endif
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if(collect($tagContext['related_tags'] ?? [])->isNotEmpty())
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/38">Related tags</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2.5">
|
||||
@foreach($tagContext['related_tags'] as $relatedIndex => $relatedTag)
|
||||
<a href="{{ route('tags.show', $relatedTag->slug) }}" data-tag-analytics-link data-tag-analytics-surface="related_chip" data-tag-analytics-tag="{{ $relatedTag->slug }}" data-tag-analytics-source-tag="{{ $tagContext['slug'] ?? '' }}" data-tag-analytics-position="{{ $relatedIndex + 1 }}" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-3.5 py-2 text-sm text-white/72 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08] hover:text-white">
|
||||
<span>#{{ $relatedTag->name }}</span>
|
||||
<span class="text-xs text-white/36">
|
||||
{{ number_format($relatedTag->shared_artworks_count ?? $relatedTag->usage_count) }}
|
||||
{{ isset($relatedTag->shared_artworks_count) ? 'shared' : 'uses' }}
|
||||
</span>
|
||||
@if(isset($relatedTag->transition_clicks) && (int) $relatedTag->transition_clicks > 0)
|
||||
<span class="text-xs text-emerald-200">{{ number_format((int) $relatedTag->transition_clicks) }} recent</span>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-3">
|
||||
@foreach(collect($tagContext['related_tags'])->take(3)->values() as $clusterIndex => $clusterTag)
|
||||
<a href="{{ route('tags.show', $clusterTag->slug) }}" data-tag-analytics-link data-tag-analytics-surface="related_cluster" data-tag-analytics-tag="{{ $clusterTag->slug }}" data-tag-analytics-source-tag="{{ $tagContext['slug'] ?? '' }}" data-tag-analytics-position="{{ $clusterIndex + 1 }}" class="rounded-2xl border border-white/10 bg-white/[0.04] p-4 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08]">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/34">Related cluster</p>
|
||||
<h3 class="mt-2 text-base font-semibold text-white">#{{ $clusterTag->name }}</h3>
|
||||
<p class="mt-2 text-sm text-white/52">
|
||||
{{ number_format($clusterTag->shared_artworks_count ?? 0) }} shared artworks with this tag feed.
|
||||
</p>
|
||||
@if(isset($clusterTag->transition_clicks) && (int) $clusterTag->transition_clicks > 0)
|
||||
<p class="mt-2 text-xs font-medium uppercase tracking-[0.18em] text-emerald-200">{{ number_format((int) $clusterTag->transition_clicks) }} recent clicks</p>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<aside class="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/40">Artworks</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($tagContext['artworks_total'] ?? 0)) }}</p>
|
||||
<p class="mt-2 text-sm text-white/45">Public approved artworks currently in this feed.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/40">Total uses</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($tagContext['usage_count'] ?? 0)) }}</p>
|
||||
<p class="mt-2 text-sm text-white/45">How often this tag is attached across the catalog.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/40">Feed tools</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<a href="{{ route('tags.index') }}" class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm font-medium text-white/72 transition hover:bg-white/[0.08] hover:text-white">
|
||||
All tags
|
||||
</a>
|
||||
<a href="{{ $tagContext['rss_url'] ?? '#' }}" class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm font-medium text-white/72 transition hover:bg-white/[0.08] hover:text-white">
|
||||
RSS
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-white/45">Jump back to discovery or subscribe to this tag feed.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- ═══════════════════════════════════════════════════════════════ --}}
|
||||
{{-- 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($subcategory_parent) && $subcategory_parent && ($subcategory_parent->url ?? null)
|
||||
? url($subcategory_parent->url)
|
||||
: (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 ?? '',
|
||||
'avatar_url' => $art->avatar_url ?? null,
|
||||
'profile_url' => $art->profile_url ?? null,
|
||||
'published_as_type' => $art->published_as_type ?? null,
|
||||
'publisher' => $art->publisher ?? 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('scripts')
|
||||
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||
@vite('resources/js/entry-pill-carousel.jsx')
|
||||
<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
|
||||
@@ -0,0 +1,284 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
use App\Banner;
|
||||
$src = $sourceArtwork;
|
||||
$useUnifiedSeo = true;
|
||||
@endphp
|
||||
|
||||
@push('head')
|
||||
<title>{{ $page_title ?? 'Similar Artworks — Skinbase' }}</title>
|
||||
<meta name="description" content="{{ $page_meta_description ?? '' }}">
|
||||
<link rel="canonical" href="{{ $page_canonical ?? url()->current() }}">
|
||||
<meta name="robots" content="{{ $page_robots ?? 'noindex,follow' }}">
|
||||
|
||||
{{-- OpenGraph --}}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="{{ $page_canonical ?? url()->current() }}" />
|
||||
<meta property="og:title" content="{{ $page_title ?? 'Similar Artworks' }}" />
|
||||
<meta property="og:description" content="{{ $page_meta_description ?? '' }}" />
|
||||
@if(!empty($src->thumb_lg))
|
||||
<meta property="og:image" content="{{ $src->thumb_lg }}" />
|
||||
@endif
|
||||
<meta property="og:site_name" content="Skinbase" />
|
||||
|
||||
{{-- Twitter card --}}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="{{ $page_title ?? 'Similar Artworks' }}" />
|
||||
<meta name="twitter:description" content="{{ $page_meta_description ?? '' }}" />
|
||||
@if(!empty($src->thumb_lg))
|
||||
<meta name="twitter:image" content="{{ $src->thumb_lg }}" />
|
||||
@endif
|
||||
|
||||
{{-- Breadcrumb structured data --}}
|
||||
@if(isset($breadcrumbs) && $breadcrumbs->isNotEmpty())
|
||||
<script type="application/ld+json">
|
||||
{!! json_encode([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'BreadcrumbList',
|
||||
'itemListElement' => $breadcrumbs->values()->map(fn ($crumb, $i) => [
|
||||
'@type' => 'ListItem',
|
||||
'position' => $i + 1,
|
||||
'name' => $crumb->name,
|
||||
'item' => url($crumb->url),
|
||||
])->all(),
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
|
||||
</script>
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto w-full max-w-screen-2xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
@php Banner::ShowResponsiveAd(); @endphp
|
||||
|
||||
<script id="similar-artworks-header-props" type="application/json">
|
||||
{!! json_encode(['artwork' => $sourceArtwork], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) !!}
|
||||
</script>
|
||||
|
||||
<div id="similar-artworks-header-root" class="mb-8"></div>
|
||||
|
||||
{{-- ══════════════════════════════════════════════════════════════════ --}}
|
||||
{{-- RESULTS SECTION (loaded asynchronously) --}}
|
||||
{{-- ══════════════════════════════════════════════════════════════════ --}}
|
||||
<section id="similar-results-section" data-artwork-id="{{ $src->id }}">
|
||||
|
||||
{{-- Section heading --}}
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold text-white">
|
||||
<span class="mr-1">✨</span> Similar Artworks
|
||||
<span id="similar-results-count" class="ml-2 text-sm font-normal text-white/40" style="display:none;"></span>
|
||||
</h2>
|
||||
<span id="similar-source-badge"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Loading skeleton --}}
|
||||
<div id="similar-loading-skeleton">
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5">
|
||||
@for($i = 0; $i < 12; $i++)
|
||||
<div class="animate-pulse">
|
||||
<div class="aspect-[3/4] rounded-2xl bg-white/[0.06]"></div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<div class="h-5 w-5 rounded-full bg-white/[0.06]"></div>
|
||||
<div class="h-3 w-24 rounded bg-white/[0.06]"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endfor
|
||||
</div>
|
||||
<p class="mt-6 text-center text-sm text-white/30">
|
||||
<svg class="mr-2 inline-block h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||
</svg>
|
||||
Finding similar artworks…
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Error state (hidden by default) --}}
|
||||
<div id="similar-error-state" style="display:none;">
|
||||
<div class="flex flex-col items-center justify-center rounded-2xl border border-rose-400/20 bg-rose-400/[0.06] px-8 py-16 text-center">
|
||||
<div class="mb-4 rounded-full border border-rose-300/20 bg-rose-500/10 p-5">
|
||||
<svg class="h-8 w-8 text-rose-300/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-white">Could not load similar artworks</h3>
|
||||
<p class="mt-2 max-w-sm text-sm text-white/50">Something went wrong. Please try again.</p>
|
||||
<button type="button" id="similar-retry-btn"
|
||||
class="mt-5 inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.06] px-4 py-2.5 text-sm font-medium text-white/80 transition hover:bg-white/[0.10] hover:text-white">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Empty state (hidden by default) --}}
|
||||
<div id="similar-empty-state" style="display:none;">
|
||||
<div class="flex flex-col items-center justify-center rounded-2xl border border-white/[0.08] bg-white/[0.03] px-8 py-16 text-center">
|
||||
<div class="mb-4 rounded-full border border-white/10 bg-white/[0.05] p-5">
|
||||
<svg class="h-8 w-8 text-white/30" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-white">No similar artworks found yet</h3>
|
||||
<p class="mt-2 max-w-sm text-sm text-white/50">
|
||||
We could not find strong matches for this artwork right now. Try browsing the full gallery or check back later.
|
||||
</p>
|
||||
<div class="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<a href="{{ $sourceArtwork->url }}"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.06] px-4 py-2.5 text-sm font-medium text-white/80 transition hover:bg-white/[0.10] hover:text-white">
|
||||
← Back to artwork
|
||||
</a>
|
||||
<a href="/explore"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-sky-400/20 bg-sky-400/[0.08] px-4 py-2.5 text-sm font-medium text-sky-300 transition hover:bg-sky-400/[0.14] hover:text-sky-200">
|
||||
Browse Explore
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Masonry grid mount point (hidden until loaded) --}}
|
||||
<div id="similar-masonry-mount" style="display:none;"
|
||||
data-gallery-type="similar"
|
||||
data-limit="24"
|
||||
class="min-h-32"
|
||||
></div>
|
||||
|
||||
{{-- Pagination (hidden until loaded) --}}
|
||||
<div id="similar-pagination" style="display:none;" class="mt-10 flex items-center justify-center gap-3"></div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@vite(['resources/js/entry-masonry-gallery.jsx', 'resources/js/entry-similar-artworks-header.jsx'])
|
||||
<script>
|
||||
(function () {
|
||||
const section = document.getElementById('similar-results-section');
|
||||
if (!section) return;
|
||||
|
||||
const artworkId = section.dataset.artworkId;
|
||||
const skeleton = document.getElementById('similar-loading-skeleton');
|
||||
const errorState = document.getElementById('similar-error-state');
|
||||
const emptyState = document.getElementById('similar-empty-state');
|
||||
const masonryMount = document.getElementById('similar-masonry-mount');
|
||||
const pagination = document.getElementById('similar-pagination');
|
||||
const countEl = document.getElementById('similar-results-count');
|
||||
const badgeEl = document.getElementById('similar-source-badge');
|
||||
const retryBtn = document.getElementById('similar-retry-btn');
|
||||
|
||||
function showOnly(el) {
|
||||
[skeleton, errorState, emptyState, masonryMount, pagination].forEach(function (node) {
|
||||
if (node) node.style.display = 'none';
|
||||
});
|
||||
if (el) el.style.display = '';
|
||||
}
|
||||
|
||||
function renderSourceBadge(source) {
|
||||
if (!badgeEl) return;
|
||||
var badges = {
|
||||
visual: { classes: 'border-violet-400/25 bg-violet-400/[0.08] text-violet-300', dot: 'bg-violet-400', text: 'Visual similarity' },
|
||||
hybrid: { classes: 'border-emerald-400/20 bg-emerald-400/[0.08] text-emerald-300', dot: 'bg-emerald-400', text: 'AI similarity' },
|
||||
tags: { classes: 'border-white/[0.08] bg-white/[0.04] text-white/40', dot: '', text: 'Tag match' },
|
||||
};
|
||||
var b = badges[source] || badges.tags;
|
||||
badgeEl.innerHTML = '<span class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] ' + b.classes + '">'
|
||||
+ (b.dot ? '<span class="h-1.5 w-1.5 rounded-full ' + b.dot + '"></span>' : '')
|
||||
+ b.text + '</span>';
|
||||
}
|
||||
|
||||
function renderPagination(data) {
|
||||
if (!pagination) return;
|
||||
if (data.last_page <= 1) return;
|
||||
|
||||
var html = '';
|
||||
|
||||
if (data.current_page <= 1) {
|
||||
html += '<span class="inline-flex items-center gap-2 rounded-xl border border-white/[0.06] bg-white/[0.03] px-4 py-2.5 text-sm text-white/30 cursor-default select-none">'
|
||||
+ '<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" /></svg>'
|
||||
+ 'Previous</span>';
|
||||
} else {
|
||||
html += '<a href="' + data.prev_page_url + '" class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.06] px-4 py-2.5 text-sm font-medium text-white/80 transition hover:bg-white/[0.10] hover:text-white">'
|
||||
+ '<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" /></svg>'
|
||||
+ 'Previous</a>';
|
||||
}
|
||||
|
||||
html += '<span class="text-sm text-white/40">Page ' + data.current_page + ' of ' + data.last_page + '</span>';
|
||||
|
||||
if (data.current_page >= data.last_page) {
|
||||
html += '<span class="inline-flex items-center gap-2 rounded-xl border border-white/[0.06] bg-white/[0.03] px-4 py-2.5 text-sm text-white/30 cursor-default select-none">'
|
||||
+ 'Next<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" /></svg></span>';
|
||||
} else {
|
||||
html += '<a href="' + data.next_page_url + '" class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.06] px-4 py-2.5 text-sm font-medium text-white/80 transition hover:bg-white/[0.10] hover:text-white">'
|
||||
+ 'Next<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" /></svg></a>';
|
||||
}
|
||||
|
||||
pagination.innerHTML = html;
|
||||
pagination.style.display = '';
|
||||
}
|
||||
|
||||
function loadResults(page) {
|
||||
showOnly(skeleton);
|
||||
if (pagination) pagination.style.display = 'none';
|
||||
|
||||
var url = '/art/' + encodeURIComponent(artworkId) + '/similar-results';
|
||||
if (page && page > 1) url += '?page=' + page;
|
||||
|
||||
fetch(url, { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
return res.json();
|
||||
})
|
||||
.then(function (json) {
|
||||
if (!json.data || json.data.length === 0) {
|
||||
showOnly(emptyState);
|
||||
return;
|
||||
}
|
||||
|
||||
renderSourceBadge(json.similarity_source || 'tags');
|
||||
|
||||
if (countEl) {
|
||||
countEl.textContent = '(' + Number(json.total).toLocaleString() + ')';
|
||||
countEl.style.display = '';
|
||||
}
|
||||
|
||||
// Hydrate the masonry gallery mount
|
||||
masonryMount.setAttribute('data-react-masonry-gallery', '');
|
||||
masonryMount.setAttribute('data-artworks', JSON.stringify(json.data));
|
||||
if (json.next_page_url) {
|
||||
masonryMount.setAttribute('data-next-page-url', json.next_page_url);
|
||||
} else {
|
||||
masonryMount.removeAttribute('data-next-page-url');
|
||||
}
|
||||
showOnly(masonryMount);
|
||||
|
||||
// Re-trigger React masonry hydration
|
||||
if (window.__hydrateMasonryGalleries) {
|
||||
window.__hydrateMasonryGalleries();
|
||||
} else {
|
||||
// Dispatch a custom event as fallback
|
||||
window.dispatchEvent(new CustomEvent('masonry-gallery:hydrate'));
|
||||
}
|
||||
|
||||
renderPagination(json);
|
||||
})
|
||||
.catch(function () {
|
||||
showOnly(errorState);
|
||||
});
|
||||
}
|
||||
|
||||
if (retryBtn) {
|
||||
retryBtn.addEventListener('click', function () {
|
||||
loadResults(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Parse page from URL if present
|
||||
var urlParams = new URLSearchParams(window.location.search);
|
||||
var initialPage = parseInt(urlParams.get('page'), 10) || 1;
|
||||
loadResults(initialPage);
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
Reference in New Issue
Block a user