Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,38 @@
{{--
Discover section-switcher pills.
Expected variable: $section (string) the active section slug, e.g. 'trending', 'for-you'
Expected variable: $isAuthenticated (bool, optional) whether the user is logged in
--}}
@php
$active = $section ?? '';
$isAuth = $isAuthenticated ?? auth()->check();
$sections = collect([
'for-you' => ['label' => 'For You', 'icon' => 'fa-wand-magic-sparkles', 'auth' => true, 'activeClass' => 'bg-yellow-500/20 text-yellow-300 border border-yellow-400/20'],
'following' => ['label' => 'Following', 'icon' => 'fa-user-group', 'auth' => true, 'activeClass' => 'bg-sky-600 text-white'],
'trending' => ['label' => 'Trending', 'icon' => 'fa-fire', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
'rising' => ['label' => 'Rising', 'icon' => 'fa-rocket', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
'fresh' => ['label' => 'Fresh', 'icon' => 'fa-bolt', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
'top-rated' => ['label' => 'Top Rated', 'icon' => 'fa-medal', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
'most-downloaded' => ['label' => 'Most Downloaded', 'icon' => 'fa-download', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
'on-this-day' => ['label' => 'On This Day', 'icon' => 'fa-calendar-day', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
]);
@endphp
<div class="flex flex-wrap items-center gap-2 text-sm">
@foreach($sections as $slug => $meta)
@if($meta['auth'] && !$isAuth)
@continue
@endif
<a href="{{ route('discover.' . $slug) }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
{{ $active === $slug
? $meta['activeClass']
: 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white' }}">
<i class="fa-solid {{ $meta['icon'] }} text-xs {{ $active === $slug && $slug === 'for-you' ? '' : '' }}"></i>
{{ $meta['label'] }}
</a>
@endforeach
</div>

View File

@@ -0,0 +1,156 @@
@extends('layouts.nova')
@php
$discoverBreadcrumbs = collect([
(object) ['name' => 'Discover', 'url' => '/discover/trending'],
(object) ['name' => 'For You', 'url' => '/discover/for-you'],
]);
@endphp
@section('content')
<x-nova-page-header
section="Discover"
title="For You"
icon="fa-wand-magic-sparkles"
:breadcrumbs="$discoverBreadcrumbs"
description="Artworks picked for you based on your taste."
headerClass="pb-6"
actionsClass="lg:pt-8"
iconClass="text-yellow-400"
>
<x-slot name="actions">
@include('web.discover._nav', ['section' => 'for-you'])
</x-slot>
</x-nova-page-header>
@php
$cacheTone = match ($feed_meta['cache_status'] ?? null) {
'hit' => 'text-emerald-200 ring-emerald-400/30 bg-emerald-500/12',
'stale' => 'text-amber-200 ring-amber-400/30 bg-amber-500/12',
default => 'text-sky-100 ring-sky-300/30 bg-sky-500/12',
};
$generatedAt = !empty($feed_meta['generated_at']) ? \Illuminate\Support\Carbon::parse($feed_meta['generated_at'])->diffForHumans() : null;
@endphp
<section class="px-6 md:px-10">
<div class="grid gap-3 rounded-[1.6rem] border border-white/8 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_42%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-5 shadow-[0_18px_60px_rgba(2,6,23,0.38)] md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
<div class="space-y-2">
<p class="text-[0.7rem] font-semibold uppercase tracking-[0.28em] text-sky-200/70">Personalized discovery</p>
<p class="max-w-3xl text-sm leading-6 text-slate-300">
This feed now runs on the same recommendation engine as the API, so your views and clicks on this page can refine what shows up next.
</p>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-slate-200/85">
<span class="inline-flex items-center gap-2 rounded-full ring-1 ring-white/12 bg-white/6 px-3 py-1.5">
<span class="text-slate-400">Model</span>
<span>{{ $feed_meta['algo_version'] ?? 'n/a' }}</span>
</span>
<span class="inline-flex items-center gap-2 rounded-full ring-1 px-3 py-1.5 {{ $cacheTone }}">
<span class="text-slate-300/70">Cache</span>
<span>{{ str_replace(['-', '_'], ' ', $feed_meta['cache_status'] ?? 'unknown') }}</span>
</span>
@if (!empty($feed_meta['total_candidates']))
<span class="inline-flex items-center gap-2 rounded-full ring-1 ring-white/12 bg-white/6 px-3 py-1.5">
<span class="text-slate-400">Candidates</span>
<span>{{ number_format((int) $feed_meta['total_candidates']) }}</span>
</span>
@endif
@if ($generatedAt)
<span class="inline-flex items-center gap-2 rounded-full ring-1 ring-white/12 bg-white/6 px-3 py-1.5">
<span class="text-slate-400">Refreshed</span>
<span>{{ $generatedAt }}</span>
</span>
@endif
</div>
</div>
</section>
{{-- ── Artwork grid (React MasonryGallery) ── --}}
@php
$galleryArtworks = $artworks->map(fn ($art) => [
'id' => $art->id,
'name' => $art->name ?? null,
'thumb' => $art->thumb_url ?? 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,
'published_at' => $art->published_at ?? null,
'content_type_name' => $art->content_type_name ?? '',
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',
'url' => $art->url ?? null,
'width' => $art->width ?? null,
'height' => $art->height ?? null,
'recommendation_source' => $art->recommendation_source ?? 'mixed',
'recommendation_reason' => $art->recommendation_reason ?? 'Picked for you',
'recommendation_score' => $art->recommendation_score,
'recommendation_algo_version' => $art->recommendation_algo_version ?? ($feed_meta['algo_version'] ?? null),
])->values();
@endphp
<section class="px-6 pt-8 md:px-10">
<div
data-react-masonry-gallery
data-artworks="{{ json_encode($galleryArtworks) }}"
data-gallery-type="for-you"
data-cursor-endpoint="{{ route('discover.for-you') }}"
data-discovery-endpoint="{{ route('api.discovery.events.store') }}"
data-algo-version="{{ $feed_meta['algo_version'] ?? '' }}"
@if (!empty($next_cursor)) data-next-cursor="{{ $next_cursor }}" @endif
data-limit="40"
class="min-h-32"
></div>
</section>
@endsection
@push('styles')
<style>
[data-nova-gallery].is-enhanced [data-gallery-grid] {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
grid-auto-rows: 8px;
gap: 1rem;
}
@media (min-width: 768px) {
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
[data-gallery-grid].force-5 { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; }
}
@media (min-width: 1600px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
[data-gallery-grid].force-5 { grid-template-columns: repeat(6, minmax(0, 1fr)) !important; }
}
@media (min-width: 2600px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
[data-gallery-grid].force-5 { grid-template-columns: repeat(7, minmax(0, 1fr)) !important; }
}
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
[data-gallery-skeleton].is-loading { display: grid !important; grid-template-columns: inherit; gap: 1rem; }
.nova-skeleton-card {
border-radius: 1rem;
min-height: 180px;
background: linear-gradient(110deg, rgba(255,255,255,.06) 8%, rgba(255,255,255,.12) 18%, rgba(255,255,255,.06) 33%);
background-size: 200% 100%;
animation: novaShimmer 1.2s linear infinite;
}
@keyframes novaShimmer {
to { background-position-x: -200%; }
}
</style>
@endpush
@push('scripts')
@vite('resources/js/entry-masonry-gallery.jsx')
@endpush

View File

@@ -0,0 +1,259 @@
@extends('layouts.nova')
@php
$discoverBreadcrumbs = collect([
(object) ['name' => 'Discover', 'url' => '/discover/trending'],
(object) ['name' => $page_title ?? 'Discover', 'url' => request()->path()],
]);
@endphp
@php
$followingActivity = collect($following_activity ?? []);
$networkTrending = collect($network_trending ?? []);
$suggestedUsers = collect($suggested_users ?? $fallback_creators ?? []);
$fallbackTrending = collect($fallback_trending ?? []);
@endphp
@section('content')
<x-nova-page-header
section="Discover"
:title="$page_title ?? 'Discover'"
:icon="$icon ?? 'fa-compass'"
:breadcrumbs="$discoverBreadcrumbs"
:description="$description ?? null"
headerClass="pb-6"
actionsClass="lg:pt-8"
>
<x-slot name="actions">
@include('web.discover._nav', ['section' => $section ?? ''])
</x-slot>
</x-nova-page-header>
@if (($section ?? null) === 'following')
<section class="px-6 pt-2 md:px-10">
@if (!empty($empty))
<div class="rounded-[32px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.08),rgba(255,255,255,0.03),rgba(249,115,22,0.06))] p-6 shadow-[0_20px_60px_rgba(2,6,23,0.24)]">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Personalized following feed</p>
<h2 class="mt-2 text-3xl font-semibold tracking-[-0.03em] text-white">Your network starts here</h2>
<p class="mt-3 max-w-2xl text-sm leading-relaxed text-slate-300">Follow a few creators to unlock a feed made of their newest art, social activity, and rising work from around your network.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/discover/trending" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-slate-200 transition-colors hover:bg-white/[0.08]">
<i class="fa-solid fa-fire fa-fw"></i>
Explore trending
</a>
<a href="/feed/following" class="inline-flex items-center gap-2 rounded-2xl border border-sky-400/20 bg-sky-500/10 px-4 py-2.5 text-sm font-medium text-sky-200 transition-colors hover:bg-sky-500/15">
<i class="fa-solid fa-newspaper fa-fw"></i>
Open post feed
</a>
</div>
</div>
</div>
@endif
<div class="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<div class="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div class="mb-4 flex items-center justify-between gap-4">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Activity from people you follow</p>
<h3 class="mt-1 text-xl font-semibold text-white">Network activity</h3>
</div>
<a href="/community/activity?filter=following" class="text-sm text-sky-300/80 transition-colors hover:text-sky-200">View all</a>
</div>
<div class="space-y-3">
@forelse ($followingActivity as $activity)
<div class="rounded-2xl border border-white/[0.06] bg-white/[0.02] px-4 py-3">
<div class="flex items-start gap-3">
<img src="{{ data_get($activity, 'user.avatar_url') ?: '/images/avatar_default.webp' }}" alt="{{ data_get($activity, 'user.username') }}" class="h-10 w-10 rounded-full object-cover ring-1 ring-white/10" loading="lazy" />
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-white">{{ data_get($activity, 'user.username') ? '@' . data_get($activity, 'user.username') : data_get($activity, 'user.name', 'Creator') }}</p>
<p class="mt-1 text-sm text-slate-300">
@if (data_get($activity, 'type') === 'follow')
started following {{ data_get($activity, 'target_user.username') ? '@' . data_get($activity, 'target_user.username') : 'another creator' }}
@elseif (data_get($activity, 'type') === 'upload')
published <a href="{{ data_get($activity, 'artwork.url') }}" class="text-sky-300 hover:text-sky-200">{{ data_get($activity, 'artwork.title', 'a new artwork') }}</a>
@elseif (in_array(data_get($activity, 'type'), ['comment', 'reply'], true))
{{ data_get($activity, 'type') === 'reply' ? 'replied on' : 'commented on' }} <a href="{{ data_get($activity, 'artwork.url') }}" class="text-sky-300 hover:text-sky-200">{{ data_get($activity, 'artwork.title', 'an artwork') }}</a>
@else
{{ ucfirst(str_replace('_', ' ', (string) data_get($activity, 'type', 'activity'))) }}
@endif
</p>
<p class="mt-1 text-xs text-slate-500">{{ data_get($activity, 'time_ago') ?: data_get($activity, 'created_at') }}</p>
</div>
</div>
</div>
@empty
<div class="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-8 text-center text-sm text-slate-400">Follow activity will appear here as your network starts moving.</div>
@endforelse
</div>
</div>
<div class="space-y-6">
<div class="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div class="mb-4">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Trending in your network</p>
<h3 class="mt-1 text-xl font-semibold text-white">Network highlights</h3>
</div>
<div class="space-y-3">
@foreach (($empty ?? false) ? $fallbackTrending->take(4) : $networkTrending->take(4) as $item)
@php
$itemId = (int) data_get($item, 'id', 0);
$itemSlug = (string) data_get($item, 'slug', '');
$itemUrl = $itemSlug !== '' && $itemId > 0
? route('art.show', ['id' => $itemId, 'slug' => $itemSlug])
: data_get($item, 'url', '#');
$itemThumb = data_get($item, 'thumb_url') ?: data_get($item, 'thumb') ?: data_get($item, 'thumbnail_url') ?: '/images/placeholder.jpg';
$itemTitle = data_get($item, 'title') ?: data_get($item, 'name', 'Artwork');
@endphp
<a href="{{ $itemUrl }}" class="flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.02] p-3 transition-colors hover:bg-white/[0.04]">
<img src="{{ $itemThumb }}" alt="{{ $itemTitle }}" class="h-16 w-16 rounded-xl object-cover" loading="lazy" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-white">{{ $itemTitle ?: 'Untitled artwork' }}</p>
<p class="truncate text-xs text-slate-400">{{ data_get($item, 'author.username') ? '@' . data_get($item, 'author.username') : data_get($item, 'username', data_get($item, 'uname')) }}</p>
@if (is_array($item) && isset($item['stats']))
<p class="mt-1 text-[11px] text-slate-500">{{ number_format((int) data_get($item, 'stats.favorites', 0)) }} favourites · {{ number_format((int) data_get($item, 'stats.views', 0)) }} views</p>
@endif
</div>
</a>
@endforeach
</div>
</div>
<div class="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div class="mb-4">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Suggested creators</p>
<h3 class="mt-1 text-xl font-semibold text-white">Who to follow next</h3>
</div>
<div class="space-y-3">
@foreach ($suggestedUsers->take(4) as $userCard)
<a href="{{ $userCard['profile_url'] ?? ('/@' . strtolower((string) ($userCard['username'] ?? ''))) }}" class="flex items-start gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.02] p-3 transition-colors hover:bg-white/[0.04]">
<img src="{{ $userCard['avatar_url'] ?? '/images/avatar_default.webp' }}" alt="{{ $userCard['username'] ?? 'creator' }}" class="h-10 w-10 rounded-full object-cover ring-1 ring-white/10" loading="lazy" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-white">{{ $userCard['name'] ?? $userCard['username'] ?? 'Creator' }}</p>
<p class="truncate text-xs text-slate-500">@{{ $userCard['username'] ?? 'creator' }}</p>
<p class="mt-1 text-xs text-slate-400">{{ data_get($userCard, 'context.follower_overlap.label') ?: data_get($userCard, 'context.shared_following.label') ?: ($userCard['reason'] ?? 'Recommended for you') }}</p>
</div>
</a>
@endforeach
</div>
</div>
</div>
</div>
</section>
@endif
{{-- ── Artwork grid (React MasonryGallery) ── --}}
@php
$galleryItems = method_exists($artworks, 'items') ? $artworks->items() : (is_iterable($artworks) ? $artworks : []);
$galleryArtworks = collect($galleryItems)->map(fn ($art) => [
'id' => $art->id,
'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 = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
@endphp
<section class="px-6 pt-8 md:px-10">
<div
data-react-masonry-gallery
data-artworks="{{ json_encode($galleryArtworks) }}"
data-gallery-type="{{ $section ?? 'discover' }}"
@if ($galleryNextPageUrl) data-next-page-url="{{ $galleryNextPageUrl }}" @endif
data-limit="24"
class="min-h-32"
></div>
</section>
@endsection
@push('styles')
<style>
[data-nova-gallery].is-enhanced [data-gallery-grid] {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
grid-auto-rows: 8px;
gap: 1rem;
}
@media (min-width: 768px) {
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
[data-gallery-grid].force-5 { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; }
}
@media (min-width: 1600px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
[data-gallery-grid].force-5 { grid-template-columns: repeat(6, minmax(0, 1fr)) !important; }
}
@media (min-width: 2600px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
[data-gallery-grid].force-5 { grid-template-columns: repeat(7, minmax(0, 1fr)) !important; }
}
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
[data-nova-gallery].is-enhanced [data-gallery-pagination] {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-top: 1.5rem;
}
[data-nova-gallery].is-enhanced [data-gallery-pagination] ul {
display: inline-flex;
gap: 0.25rem;
align-items: center;
padding: 0;
margin: 0;
list-style: none;
}
[data-nova-gallery].is-enhanced [data-gallery-pagination] li a,
[data-nova-gallery].is-enhanced [data-gallery-pagination] li span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
padding: 0 0.5rem;
background: rgba(255,255,255,0.03);
color: #e6eef8;
border: 1px solid rgba(255,255,255,0.04);
text-decoration: none;
font-size: 0.875rem;
}
[data-gallery-skeleton].is-loading { display: grid !important; grid-template-columns: inherit; gap: 1rem; }
.nova-skeleton-card {
border-radius: 1rem;
min-height: 180px;
background: linear-gradient(110deg, rgba(255,255,255,.06) 8%, rgba(255,255,255,.12) 18%, rgba(255,255,255,.06) 33%);
background-size: 200% 100%;
animation: novaShimmer 1.2s linear infinite;
}
@keyframes novaShimmer {
to { background-position-x: -200%; }
}
</style>
@endpush
@push('scripts')
@vite('resources/js/entry-masonry-gallery.jsx')
@endpush