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

353 lines
21 KiB
PHP

@extends('layouts.nova')
@php
use App\Banner;
$src = $sourceArtwork;
@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="container-fluid legacy-page">
@php Banner::ShowResponsiveAd(); @endphp
$useUnifiedSeo = true;
<div class="pt-0">
{{-- Source info --}}
<div class="min-w-0 flex-1 space-y-2">
<h1 class="text-2xl font-bold leading-tight text-white md:text-3xl">
Artworks similar to
<a href="{{ $src->url }}" class="underline decoration-white/20 underline-offset-4 transition hover:decoration-sky-400 focus-visible:outline-none">{{ $src->title }}</a>
</h1>
{{-- Author & category --}}
<div class="flex flex-wrap items-center gap-3 text-sm text-white/50">
@if(!empty($src->author_name))
<span class="flex items-center gap-2">
@if(!empty($src->author_avatar))
<img src="{{ $src->author_avatar }}"
alt="{{ $src->author_name }}"
class="h-5 w-5 rounded-full object-cover ring-1 ring-white/20"
onerror="this.style.display='none'">
@endif
<a href="/{{ $src->author_username }}" class="font-medium text-white/70 hover:text-white transition">{{ $src->author_name }}</a>
</span>
@endif
@if(!empty($src->category_name))
<span class="inline-flex items-center gap-1 rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-0.5 text-xs font-medium text-white/60">
{{ $src->category_name }}
</span>
@endif
@if(!empty($src->content_type_name))
<span class="inline-flex items-center gap-1 rounded-full border border-sky-400/20 bg-sky-400/[0.08] px-2.5 py-0.5 text-xs font-medium text-sky-300">
{{ $src->content_type_name }}
</span>
@endif
</div>
{{-- Tags --}}
@if(!empty($src->tag_slugs))
<div class="flex flex-wrap gap-1.5">
@foreach($src->tag_slugs as $tagSlug)
<a href="{{ route('tags.show', $tagSlug) }}"
class="rounded-full border border-white/[0.08] bg-white/[0.04] px-2.5 py-0.5 text-xs text-white/50 transition hover:border-sky-400/30 hover:bg-sky-400/[0.07] hover:text-white/80">
#{{ $tagSlug }}
</a>
@endforeach
</div>
@endif
{{-- Actions --}}
<div class="flex items-center gap-3 pt-1">
<a href="{{ $src->url }}"
class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.06] px-4 py-2 text-sm font-medium text-white/80 transition hover:bg-white/[0.10] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/60">
<svg class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to artwork
</a>
@if(!empty($src->content_type_slug))
<a href="/{{ $src->content_type_slug }}"
class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/60 transition hover:bg-white/[0.08] hover:text-white/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/60">
Browse {{ $src->content_type_name ?: 'artworks' }}
</a>
@endif
</div>
</div>
</div>
</div>
{{-- ══════════════════════════════════════════════════════════════ --}}
{{-- RESULTS SECTION (loaded asynchronously) --}}
{{-- ══════════════════════════════════════════════════════════════ --}}
<section class="px-6 pb-10 pt-8 md:px-10" 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&hellip;
</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>
</main>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
@vite('resources/js/entry-masonry-gallery.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