feat: ship creator journey v2 and profile updates

This commit is contained in:
2026-04-12 21:42:07 +02:00
parent a2457f4e49
commit d5cff21ea2
335 changed files with 20147 additions and 1545 deletions

View File

@@ -44,6 +44,7 @@
<img
src="{{ $art->thumb_url ?? 'https://files.skinbase.org/default/missing_md.webp' }}"
@if(!empty($art->thumb_srcset)) srcset="{{ $art->thumb_srcset }}" @endif
sizes="70px"
alt="{{ $art->name ?? '' }}"
class="img-thumbnail"
style="width:70px;height:70px;object-fit:cover"

View File

@@ -59,6 +59,7 @@
<img
src="{{ $artwork->thumb_url ?? $artwork->thumb }}"
@if(!empty($artwork->thumb_srcset)) srcset="{{ $artwork->thumb_srcset }}" @endif
sizes="(max-width: 992px) 100vw, 400px"
class="img-responsive"
alt="{{ $artwork->title }}"
>

View File

@@ -38,6 +38,7 @@
<img
src="{{ $artwork->thumb_url ?? $artwork->thumb }}"
@if(!empty($artwork->thumb_srcset)) srcset="{{ $artwork->thumb_srcset }}" @endif
sizes="70px"
alt="{{ $artwork->title }}"
class="img-thumbnail"
style="width:70px;height:70px;object-fit:cover"

View File

@@ -2,6 +2,7 @@
'art',
'loading' => 'lazy',
'fetchpriority' => null,
'imageSizes' => '(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1536px) 25vw, 320px',
])
@php
@@ -105,6 +106,7 @@
$imgSrcset = (string) ($art->thumb_srcset ?? $art->thumbnail_srcset ?? $imgSrc);
$imgAvifSrcset = (string) ($art->thumb_avif_srcset ?? $imgSrcset);
$imgWebpSrcset = (string) ($art->thumb_webp_srcset ?? $imgSrcset);
$imgSizes = trim((string) $imageSizes);
$resolveDimension = function ($value, string $field, $fallback) {
if (is_numeric($value)) {
@@ -163,8 +165,17 @@
if ($resolution !== '') {
$metaParts[] = $resolution;
}
$maturity = data_get($art, 'maturity');
if (is_array($maturity)) {
$maturity = (object) $maturity;
}
$shouldHide = (bool) data_get($maturity, 'should_hide', false);
$shouldBlur = (bool) data_get($maturity, 'should_blur', false);
$isMatureArtwork = (bool) data_get($maturity, 'is_mature_effective', false);
@endphp
@unless($shouldHide)
<article class="nova-card gallery-item artwork" itemscope itemtype="https://schema.org/ImageObject"
data-art-id="{{ $art->id ?? '' }}"
data-art-url="{{ $cardUrl }}"
@@ -181,15 +192,19 @@
<div class="absolute left-3 top-3 z-30 rounded-md bg-black/55 px-2 py-1 text-xs text-white backdrop-blur-sm">{{ $category }}</div>
@endif
@if($shouldBlur && $isMatureArtwork)
<div class="absolute right-3 top-3 z-30 rounded-md bg-amber-500/85 px-2 py-1 text-xs font-semibold text-black shadow-lg shadow-black/30 backdrop-blur-sm">Mature</div>
@endif
<div class="nova-card-media relative overflow-hidden bg-neutral-900"@if($imgAspectRatio) style="aspect-ratio: {{ $imgAspectRatio }};"@endif>
<div class="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none"></div>
<picture>
<source srcset="{{ $imgAvifSrcset }}" type="image/avif">
<source srcset="{{ $imgWebpSrcset }}" type="image/webp">
<source srcset="{{ $imgAvifSrcset }}" @if($imgSizes !== '') sizes="{{ $imgSizes }}" @endif type="image/avif">
<source srcset="{{ $imgWebpSrcset }}" @if($imgSizes !== '') sizes="{{ $imgSizes }}" @endif type="image/webp">
<img
src="{{ $imgSrc }}"
srcset="{{ $imgSrcset }}"
sizes="(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw"
@if($imgSizes !== '') sizes="{{ $imgSizes }}" @endif
loading="{{ $loading }}"
decoding="{{ $loading === 'eager' ? 'sync' : 'async' }}"
@if($fetchpriority) fetchpriority="{{ $fetchpriority }}" @endif
@@ -197,11 +212,18 @@
alt="{{ e($title) }}"
@if($imgWidth) width="{{ $imgWidth }}" @endif
@if($imgHeight) height="{{ $imgHeight }}" @endif
class="{{ $imgAspectRatio ? 'h-full w-full object-cover' : 'w-full h-auto' }} transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
class="{{ $imgAspectRatio ? 'h-full w-full object-cover' : 'w-full h-auto' }} transition-[transform,filter] duration-300 ease-out {{ $shouldBlur ? 'blur-xl scale-[1.08]' : 'group-hover:scale-[1.04]' }}"
itemprop="thumbnailUrl"
/>
</picture>
@if($shouldBlur && $isMatureArtwork)
<div class="pointer-events-none absolute inset-0 z-10 bg-black/25"></div>
<div class="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/85 via-black/45 to-transparent px-3 py-3 text-xs font-semibold uppercase tracking-[0.18em] text-white/90">
Mature content blurred
</div>
@endif
<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-start justify-between gap-3 text-xs text-white/80">
@@ -222,3 +244,4 @@
<span class="sr-only">{{ $title }} by {{ $author }}</span>
</a>
</article>
@endunless

View File

@@ -6,6 +6,8 @@
@if($artwork['thumb'])
<div class="aspect-video w-full overflow-hidden bg-nova-700">
<img src="{{ $artwork['thumb'] }}"
@if(!empty($artwork['thumb_srcset'])) srcset="{{ $artwork['thumb_srcset'] }}" @endif
sizes="(max-width: 768px) 50vw, 240px"
alt="{{ $artwork['title'] }}"
loading="lazy"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 opacity-80 group-hover:opacity-100" />

View File

@@ -48,81 +48,19 @@
@endpush
@section('content')
<div class="container-fluid legacy-page">
<div class="mx-auto w-full max-w-screen-2xl px-4 py-6 sm:px-6 lg:px-8">
@php Banner::ShowResponsiveAd(); @endphp
<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>
<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>
{{-- 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
<div id="similar-artworks-header-root" class="mb-8"></div>
@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 }}">
{{-- ══════════════════════════════════════════════════════════════════ --}}
{{-- 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">
@@ -210,17 +148,12 @@
{{-- 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>
</section>
</div>
@endsection
@push('scripts')
@vite('resources/js/entry-masonry-gallery.jsx')
@vite(['resources/js/entry-masonry-gallery.jsx', 'resources/js/entry-similar-artworks-header.jsx'])
<script>
(function () {
const section = document.getElementById('similar-results-section');

View File

@@ -1,6 +1,8 @@
@php
$gridVersion = request()->query('grid') === 'v2' ? 'v2' : 'v1';
$deferToolbarSearch = request()->routeIs('index');
$deferFontAwesome = request()->routeIs('index');
$deferWebManifest = request()->routeIs('index');
$isInertiaPage = isset($page) && is_array($page);
$shouldRenderBladeSeo = ($useUnifiedSeo ?? false) && (($renderBladeSeo ?? false) || ! $isInertiaPage);
$novaViteEntries = [
@@ -27,60 +29,148 @@
{{-- Global RSS feed discovery --}}
<link rel="alternate" type="application/rss+xml" title="Skinbase Latest Artworks" href="{{ url('/rss') }}">
<!-- Icons (kept for now to preserve current visual output) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
<!-- Icons: keep CDN delivery, but keep homepage webfonts out of the initial critical path -->
@if(!$deferFontAwesome)
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="dns-prefetch" href="//cdnjs.cloudflare.com">
<link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" as="style" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" media="print" onload="this.media='all'" crossorigin>
<noscript>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" crossorigin>
</noscript>
@endif
<link rel="icon" type="image/png" href="/favicon/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/favicon/site.webmanifest" />
@if(!$deferWebManifest)
<link rel="manifest" href="/favicon/site.webmanifest" />
@endif
@vite($novaViteEntries)
<script>
window.SKINBASE_LIMITS = Object.assign({}, window.SKINBASE_LIMITS || {}, {
tags: Object.assign({}, (window.SKINBASE_LIMITS && window.SKINBASE_LIMITS.tags) || {}, {
max_user_tags: @json((int) config('tags.max_user_tags', 30)),
}),
});
</script>
@stack('head')
@if($deferToolbarSearch)
<script type="module">
(() => {
const searchEntryUrl = @js(Vite::asset('resources/js/entry-search.jsx'));
const triggerEvents = ['pointerdown', 'touchstart', 'focusin'];
let searchLoaded = false;
const loadSearch = () => {
const loadSearch = (intent = null) => {
if (searchLoaded) {
return;
}
if (intent) {
window.__sbSearchIntent = intent;
}
searchLoaded = true;
cleanup();
import(searchEntryUrl);
};
const resolveIntent = (eventTarget) => {
return eventTarget?.closest?.('[data-search-intent]')?.getAttribute('data-search-intent') || null;
};
const handlePointerEnter = () => {
loadSearch();
};
const handleActivate = (event) => {
const intent = resolveIntent(event.target);
loadSearch(intent);
};
const handleShortcut = (event) => {
if (!((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k')) {
return;
}
event.preventDefault();
loadSearch(window.matchMedia('(max-width: 767px)').matches ? 'mobile' : 'desktop');
};
const onReady = () => {
const searchRoot = document.getElementById('topbar-search-root');
if (!searchRoot) {
return;
}
triggerEvents.forEach((eventName) => {
searchRoot.addEventListener(eventName, loadSearch, { once: true, passive: true });
});
if ('requestIdleCallback' in window) {
window.requestIdleCallback(loadSearch, { timeout: 2000 });
} else {
window.setTimeout(loadSearch, 1500);
}
searchRoot.addEventListener('pointerenter', handlePointerEnter, { once: true, passive: true });
searchRoot.addEventListener('click', handleActivate, { passive: true });
searchRoot.addEventListener('touchstart', handleActivate, { passive: true });
document.addEventListener('keydown', handleShortcut);
};
const cleanup = () => {
const searchRoot = document.getElementById('topbar-search-root');
if (!searchRoot) {
if (searchRoot) {
searchRoot.removeEventListener('pointerenter', handlePointerEnter);
searchRoot.removeEventListener('click', handleActivate);
searchRoot.removeEventListener('touchstart', handleActivate);
}
document.removeEventListener('keydown', handleShortcut);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onReady, { once: true });
} else {
onReady();
}
})();
</script>
@endif
@if($deferFontAwesome)
<script>
(() => {
const href = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css';
const linkId = 'deferred-font-awesome';
let loaded = false;
const loadFontAwesome = () => {
if (loaded || document.getElementById(linkId)) {
return;
}
triggerEvents.forEach((eventName) => {
searchRoot.removeEventListener(eventName, loadSearch);
});
loaded = true;
cleanup();
const link = document.createElement('link');
link.id = linkId;
link.rel = 'stylesheet';
link.href = href;
link.crossOrigin = 'anonymous';
document.head.appendChild(link);
};
const onReady = () => {
const toolbar = document.getElementById('nova-toolbar');
if (toolbar) {
toolbar.addEventListener('pointerenter', loadFontAwesome, { once: true, passive: true });
toolbar.addEventListener('focusin', loadFontAwesome, { once: true, passive: true });
toolbar.addEventListener('pointerdown', loadFontAwesome, { once: true, passive: true });
}
};
const cleanup = () => {
const toolbar = document.getElementById('nova-toolbar');
if (toolbar) {
toolbar.removeEventListener('pointerenter', loadFontAwesome);
toolbar.removeEventListener('focusin', loadFontAwesome);
toolbar.removeEventListener('pointerdown', loadFontAwesome);
}
};
if (document.readyState === 'loading') {

View File

@@ -2,7 +2,7 @@
<footer class="border-t border-neutral-800 bg-nova">
<div class="px-6 md:px-10 py-8 flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div class="text-xl font-semibold tracking-wide flex items-center gap-1">
<img src="https://cdn.skinbase.org/images/skinbase_logo_64.webp" alt="Skinbase" width="320" height="64" class="h-16 w-auto object-contain">
<img src="https://cdn.skinbase.org/images/skinbase_logo_64.webp" alt="" width="320" height="64" class="h-16 w-80 object-contain">
<span class="sr-only">Skinbase</span>
</div>

View File

@@ -1,4 +1,4 @@
<header class="fixed inset-x-0 top-0 z-50 h-16 bg-black/40 backdrop-blur border-b border-white/10">
<header id="nova-toolbar" class="fixed inset-x-0 top-0 z-50 h-16 bg-black/40 backdrop-blur border-b border-white/10">
<div class="mx-auto w-full h-full px-3 sm:px-4 flex items-center gap-2 sm:gap-3">
<!-- Mobile hamburger -->
@@ -19,15 +19,29 @@
<!-- Logo -->
<a href="/" class="flex items-center gap-2 pr-2 shrink-0">
<img src="/gfx/sb_logo.webp" alt="Skinbase.org" width="289" height="100" class="h-9 w-auto rounded-sm shadow-sm object-contain">
<img src="/gfx/sb_logo.webp" alt="" width="289" height="100" class="h-9 w-auto rounded-sm shadow-sm object-contain">
<span class="sr-only">Skinbase.org</span>
</a>
<!-- Desktop left nav: Discover · Browse · Groups · Creators · Community -->
@php
$toolbarContentTypes = collect($toolbarContentTypes ?? []);
$toolbarContentTypeSlugs = $toolbarContentTypes
->pluck('slug')
->filter()
->map(fn ($slug) => strtolower((string) $slug))
->values()
->all();
$toolbarContentTypeIcons = [
'photography' => 'fa-camera',
'wallpapers' => 'fa-desktop',
'skins' => 'fa-layer-group',
'digital-art' => 'fa-palette',
'other' => 'fa-folder-open',
];
$navSection = match(true) {
request()->is('discover', 'discover/*') => 'discover',
request()->is('browse', 'photography', 'wallpapers', 'skins', 'other', 'tags', 'tags/*') => 'browse',
request()->is(...array_merge(['browse', 'tags', 'tags/*'], $toolbarContentTypeSlugs)) => 'browse',
request()->is('groups', 'groups/*') => 'groups',
request()->is('creators', 'creators/*', 'stories', 'stories/*', 'following', 'leaderboard') => 'creators',
request()->is('forum', 'forum/*', 'news', 'news/*') => 'community',
@@ -86,18 +100,15 @@
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/explore">
<i class="fa-solid fa-border-all w-4 text-center text-sb-muted"></i>All Artworks
</a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/photography">
<i class="fa-solid fa-camera w-4 text-center text-sb-muted"></i>Photography
</a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/wallpapers">
<i class="fa-solid fa-desktop w-4 text-center text-sb-muted"></i>Wallpapers
</a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/skins">
<i class="fa-solid fa-layer-group w-4 text-center text-sb-muted"></i>Skins
</a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/other">
<i class="fa-solid fa-folder-open w-4 text-center text-sb-muted"></i>Other
@foreach($toolbarContentTypes as $contentType)
@php
$contentTypeSlug = strtolower((string) $contentType->slug);
$contentTypeIcon = $toolbarContentTypeIcons[$contentTypeSlug] ?? 'fa-folder-open';
@endphp
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/{{ $contentTypeSlug }}">
<i class="fa-solid {{ $contentTypeIcon }} w-4 text-center text-sb-muted"></i>{{ $contentType->name }}
</a>
@endforeach
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('categories.index') }}">
<i class="fa-solid fa-folder-open w-4 text-center text-sb-muted"></i>Categories
</a>
@@ -197,7 +208,37 @@
<!-- Search: collapsed pill expands on click -->
<div class="flex-1 flex items-center justify-center px-1 sm:px-2 min-w-0">
<div id="topbar-search-root" class="w-full flex justify-center"></div>
<div id="topbar-search-root" class="w-full flex justify-center">
@if(request()->routeIs('index'))
<button
type="button"
data-search-intent="mobile"
aria-label="Open search"
class="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-lg text-soft hover:text-white hover:bg-white/5 transition-colors"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
</button>
<div class="hidden md:block w-full" style="max-width: clamp(8.75rem, 8vw + 4rem, 10.5rem);">
<button
type="button"
data-search-intent="desktop"
aria-label="Search"
class="w-full h-10 flex items-center gap-2.5 px-3.5 rounded-lg bg-white/[0.05] border border-white/[0.09] text-soft hover:bg-white/[0.1] hover:border-white/20 hover:text-white transition-colors"
>
<svg class="w-4 h-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="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
<span class="text-sm flex-1 text-left truncate">Search</span>
<kbd class="hidden lg:inline-flex shrink-0 items-center gap-0.5 text-[10px] font-medium px-1.5 py-0.5 rounded-md bg-white/[0.06] border border-white/[0.10] text-white/30">
CtrlK
</kbd>
</button>
</div>
@endif
</div>
</div>
@auth
@@ -230,13 +271,28 @@
<div class="relative">
<button class="flex items-center gap-2 pl-1.5 sm:pl-2 pr-2 sm:pr-3 h-10 rounded-lg hover:bg-white/5 transition-colors shrink-0" data-dd="user">
@php
$toolbarUser = Auth::user();
$toolbarUserId = (int) ($userId ?? Auth::id() ?? 0);
$toolbarAvatarHash = $avatarHash ?? optional(Auth::user())->profile->avatar_hash ?? null;
$toolbarAvatarHash = $avatarHash ?? optional($toolbarUser)->profile->avatar_hash ?? null;
$toolbarEmailVerified = method_exists($toolbarUser, 'hasVerifiedEmail')
? $toolbarUser->hasVerifiedEmail()
: !empty($toolbarUser?->email_verified_at);
$toolbarVerificationNoticeRoute = Route::has('verification.notice') ? route('verification.notice') : null;
$toolbarVerificationSendRoute = Route::has('verification.send') ? route('verification.send') : null;
$toolbarVerificationLinkSent = session('status') === 'verification-link-sent';
@endphp
<img class="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
src="{{ \App\Support\AvatarUrl::forUser($toolbarUserId, $toolbarAvatarHash, 64) }}"
alt="{{ $displayName ?? 'User' }}" />
<span class="relative shrink-0">
<img class="w-7 h-7 rounded-full object-cover ring-1 {{ $toolbarEmailVerified ? 'ring-white/10' : 'ring-amber-400/30' }}"
src="{{ \App\Support\AvatarUrl::forUser($toolbarUserId, $toolbarAvatarHash, 64) }}"
alt="" />
@unless($toolbarEmailVerified)
<span class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-amber-400 ring-2 ring-[#0b1220]" aria-hidden="true"></span>
@endunless
</span>
<span class="hidden min-[900px]:inline-block max-w-[8rem] truncate text-sm text-white/90">{{ $displayName ?? 'User' }}</span>
@unless($toolbarEmailVerified)
<span class="hidden xl:inline-flex items-center rounded-full border border-amber-400/25 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-100">Verify email</span>
@endunless
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6" />
</svg>
@@ -262,6 +318,50 @@
: route('setup.username.create');
@endphp
@unless($toolbarEmailVerified)
<div class="px-3 pt-3 pb-2">
<div class="rounded-xl border border-amber-400/20 bg-amber-500/10 px-3 py-3 text-sm text-amber-50">
<div class="flex items-start gap-3">
<span class="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-amber-400/15 text-amber-200">
<i class="fa-solid fa-envelope-circle-check text-sm"></i>
</span>
<div class="min-w-0 flex-1">
<div class="font-semibold text-amber-100">Verify your email</div>
<p class="mt-1 text-xs leading-5 text-amber-100/85">
Confirm <span class="font-medium text-amber-50">{{ $toolbarUser?->email }}</span> to unlock medals and other mature-account actions.
</p>
@if($toolbarVerificationLinkSent)
<div class="mt-2 rounded-lg border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-2 text-[11px] font-medium text-emerald-100">
A fresh verification link was sent to your email.
</div>
@endif
<div class="mt-3 flex flex-wrap gap-2">
@if($toolbarVerificationNoticeRoute)
<a class="inline-flex items-center rounded-lg border border-amber-300/25 bg-white/5 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-amber-50 transition-colors hover:bg-white/10"
href="{{ $toolbarVerificationNoticeRoute }}">
Open verification page
</a>
@endif
@if($toolbarVerificationSendRoute)
<form method="POST" action="{{ $toolbarVerificationSendRoute }}">
@csrf
<button type="submit" class="inline-flex items-center rounded-lg border border-amber-300/25 bg-amber-300/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-amber-50 transition-colors hover:bg-amber-300/20">
Resend verification email
</button>
</form>
@endif
</div>
</div>
</div>
</div>
</div>
<div class="border-t border-panel my-1"></div>
@endunless
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeUpload }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-upload text-xs text-sb-muted"></i></span>
Upload
@@ -393,10 +493,13 @@
</button>
<div id="mobileSectionBrowse" data-mobile-section-panel class="hidden mt-0.5 space-y-0.5">
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/browse"><i class="fa-solid fa-border-all w-4 text-center text-sb-muted"></i>All Artworks</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/photography"><i class="fa-solid fa-camera w-4 text-center text-sb-muted"></i>Photography</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/wallpapers"><i class="fa-solid fa-desktop w-4 text-center text-sb-muted"></i>Wallpapers</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/skins"><i class="fa-solid fa-layer-group w-4 text-center text-sb-muted"></i>Skins</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/other"><i class="fa-solid fa-folder-open w-4 text-center text-sb-muted"></i>Other</a>
@foreach($toolbarContentTypes as $contentType)
@php
$contentTypeSlug = strtolower((string) $contentType->slug);
$contentTypeIcon = $toolbarContentTypeIcons[$contentTypeSlug] ?? 'fa-folder-open';
@endphp
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/{{ $contentTypeSlug }}"><i class="fa-solid {{ $contentTypeIcon }} w-4 text-center text-sb-muted"></i>{{ $contentType->name }}</a>
@endforeach
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/tags"><i class="fa-solid fa-tags w-4 text-center text-sb-muted"></i>Tags</a>
</div>
</div>

View File

@@ -0,0 +1,18 @@
@extends('layouts.nova')
@push('head')
<meta name="csrf-token" content="{{ csrf_token() }}" />
@vite(['resources/js/moderation.jsx'])
<style>
body.page-moderation main { padding-top: 4rem; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.body.classList.add('page-moderation')
})
</script>
@endpush
@section('content')
@inertia
@endsection

View File

@@ -4,37 +4,6 @@
@section('content')
<div class="px-6 pt-10 pb-8 md:px-10" id="search-page" data-q="{{ $q ?? '' }}">
@php
$hasQuery = isset($q) && $q !== '';
$resultCount = method_exists($artworks, 'total') ? (int) $artworks->total() : 0;
$groupResults = collect($groups ?? []);
$groupResultCount = $groupResults->count();
$newsResults = collect($news ?? []);
$newsResultCount = $newsResults->count();
$hasAnyResults = $resultCount > 0 || $groupResultCount > 0 || $newsResultCount > 0;
$galleryArtworks = collect($artworks->items())->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,
'views' => $art->views ?? null,
'likes' => $art->likes ?? null,
'downloads' => $art->downloads ?? null,
])->values();
$galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
@endphp
<div class="mb-8">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>

View File

@@ -26,6 +26,7 @@
<img
src="{{ $item->thumb_url ?? '' }}"
@if(!empty($item->thumb_srcset)) srcset="{{ $item->thumb_srcset }}" @endif
sizes="(max-width: 768px) 176px, 208px"
alt="{{ $item->name ?? 'Featured artwork' }}"
loading="lazy"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"

View File

@@ -26,6 +26,7 @@
'profile_url' => $art->profile_url ?? null,
'published_as_type' => $art->published_as_type ?? null,
'publisher' => $art->publisher ?? null,
'maturity' => $art->maturity ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'width' => $art->width ?? null,

View File

@@ -4,10 +4,16 @@
@push('head')
{{-- Preload hero image for faster LCP --}}
@if(!empty($props['hero']['thumb_lg']))
<link rel="preload" as="image" href="{{ $props['hero']['thumb_lg'] }}">
@if(!empty($props['hero']['thumb']) || !empty($props['hero']['thumb_lg']))
<link
rel="preload"
as="image"
href="{{ $props['hero']['thumb_lg'] ?? $props['hero']['thumb'] }}"
@if(!empty($props['hero']['thumb_srcset'])) imagesrcset="{{ $props['hero']['thumb_srcset'] }}" imagesizes="100vw" @endif
fetchpriority="high"
>
@elseif(!empty($props['hero']['thumb']))
<link rel="preload" as="image" href="{{ $props['hero']['thumb'] }}">
<link rel="preload" as="image" href="{{ $props['hero']['thumb'] }}" fetchpriority="high">
@endif
@endpush
@@ -21,17 +27,18 @@
{!! json_encode($props, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) !!}
</script>
<div id="homepage-root" class="min-h-[40vh]">
{{-- Loading skeleton (replaced by React on hydration) --}}
<div class="space-y-10 px-4 pt-10 sm:px-6 lg:px-8">
<div class="h-14 rounded-2xl bg-nova-800/70"></div>
<div class="grid gap-4 lg:grid-cols-4">
<div class="aspect-video rounded-2xl bg-nova-800/70"></div>
<div class="aspect-video rounded-2xl bg-nova-800/70"></div>
<div class="aspect-video rounded-2xl bg-nova-800/70"></div>
<div class="aspect-video rounded-2xl bg-nova-800/70"></div>
</div>
</div>
<div id="homepage-root">
@if(!empty($props['is_logged_in']))
@include('web.home.skeleton-sections', [
'showWelcomeSpacer' => true,
'variants' => ['gallery', 'gallery', 'gallery', 'gallery', 'gallery', 'gallery', 'gallery', 'gallery', 'collections', 'groups', 'categories', 'creators', 'tags', 'creators', 'news', 'cta'],
])
@else
@include('web.home.skeleton-sections', [
'showWelcomeSpacer' => false,
'variants' => ['gallery', 'gallery', 'gallery', 'gallery', 'gallery', 'collections', 'groups', 'categories', 'tags', 'creators', 'news', 'cta'],
])
@endif
</div>
@vite(['resources/js/Pages/Home/HomePage.jsx'])

View File

@@ -2,6 +2,8 @@
$heroArtwork = $artwork ?? null;
$fallbackImage = 'https://files.skinbase.org/default/missing_lg.webp';
$heroImage = $heroArtwork['thumb_lg'] ?? $heroArtwork['thumb'] ?? $fallbackImage;
$heroImageSrcset = $heroArtwork['thumb_srcset'] ?? null;
$heroImageSizes = '100vw';
@endphp
@if (!$heroArtwork)
@@ -15,19 +17,23 @@
Discover. Create. Inspire.
</p>
<div class="mt-4 flex flex-wrap gap-3">
<a href="/discover/trending" class="rounded-xl bg-accent px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:brightness-110">Explore Trending</a>
<a href="/discover/trending" class="btn-accent-solid rounded-xl px-5 py-2 text-sm font-semibold">Explore Trending</a>
</div>
</div>
</section>
@else
<section class="group relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
<img
src="{{ $heroImage }}"
alt="{{ $heroArtwork['title'] ?? 'Featured artwork' }}"
class="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.02]"
fetchpriority="high"
decoding="async"
/>
<picture>
<img
src="{{ $heroImage }}"
@if($heroImageSrcset) srcset="{{ $heroImageSrcset }}" sizes="{{ $heroImageSizes }}" @endif
alt="{{ $heroArtwork['title'] ?? 'Featured artwork' }}"
class="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.02]"
fetchpriority="high"
loading="eager"
decoding="sync"
/>
</picture>
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/55 to-transparent"></div>
@@ -47,7 +53,7 @@
<div class="mt-4 flex flex-wrap gap-3">
<a
href="/discover/trending"
class="rounded-xl bg-accent px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:brightness-110"
class="btn-accent-solid rounded-xl px-5 py-2 text-sm font-semibold"
>
Explore Trending
</a>

View File

@@ -0,0 +1,67 @@
@if(!empty($showWelcomeSpacer))
<div class="mt-10 px-4 sm:px-6 lg:px-8" aria-hidden="true">
<div class="h-20 animate-pulse rounded-[28px] border border-white/10 bg-nova-800/70"></div>
</div>
@endif
@foreach(($variants ?? []) as $variant)
@if($variant === 'tags')
<section class="mt-14 px-4 sm:px-6 lg:px-8" aria-hidden="true">
<div class="mb-5 h-8 w-48 animate-pulse rounded-xl bg-nova-800/70"></div>
<div class="flex flex-wrap gap-2">
<div class="h-9 w-24 animate-pulse rounded-full bg-nova-800/70"></div>
<div class="h-9 w-32 animate-pulse rounded-full bg-nova-800/70"></div>
<div class="h-9 w-28 animate-pulse rounded-full bg-nova-800/70"></div>
<div class="h-9 w-36 animate-pulse rounded-full bg-nova-800/70"></div>
<div class="h-9 w-24 animate-pulse rounded-full bg-nova-800/70"></div>
<div class="h-9 w-32 animate-pulse rounded-full bg-nova-800/70"></div>
<div class="h-9 w-28 animate-pulse rounded-full bg-nova-800/70"></div>
<div class="h-9 w-36 animate-pulse rounded-full bg-nova-800/70"></div>
</div>
</section>
@elseif($variant === 'cta')
<section class="mt-14 px-4 sm:px-6 lg:px-8" aria-hidden="true">
<div class="h-40 animate-pulse rounded-[28px] border border-white/10 bg-nova-800/70"></div>
</section>
@else
@php
$showSubtitle = in_array($variant, ['collections', 'groups', 'news'], true);
$gridClass = match ($variant) {
'creators' => 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
'news' => 'grid-cols-1',
'categories' => 'grid-cols-2 lg:grid-cols-4',
'collections' => 'grid-cols-1 lg:grid-cols-2 xl:grid-cols-3',
'groups' => 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-4',
default => 'grid-cols-2 xl:grid-cols-4',
};
$cardClass = match ($variant) {
'categories' => 'h-28 rounded-2xl',
'news' => 'h-24 rounded-2xl',
'creators' => 'h-64 rounded-2xl',
'collections', 'groups' => 'h-80 rounded-[28px]',
default => 'aspect-[4/3] rounded-2xl',
};
$cardCount = match ($variant) {
'creators' => 6,
'news' => 4,
default => 4,
};
@endphp
<section class="mt-14 px-4 sm:px-6 lg:px-8" aria-hidden="true">
<div class="mb-5 flex items-center justify-between gap-4">
<div>
<div class="h-8 w-48 animate-pulse rounded-xl bg-nova-800/70"></div>
@if($showSubtitle)
<div class="mt-3 h-4 w-80 max-w-full animate-pulse rounded bg-nova-800/60"></div>
@endif
</div>
<div class="hidden h-5 w-24 animate-pulse rounded bg-nova-800/60 sm:block"></div>
</div>
<div class="grid gap-4 {{ $gridClass }}">
@for($i = 0; $i < $cardCount; $i++)
<div class="animate-pulse bg-nova-800/70 {{ $cardClass }}"></div>
@endfor
</div>
</section>
@endif
@endforeach

View File

@@ -70,12 +70,14 @@
<input
id="tags-search"
type="search"
role="combobox"
name="q"
value="{{ $query }}"
placeholder="Search aesthetics, games, styles..."
autocomplete="off"
spellcheck="false"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="tags-search-suggestions"
data-tags-search-input