feat(playlist): enhance public playlist index with hero stats and loading optimizations
Add a redesigned hero section to the public playlist page displaying community-generated totals for playlists and episodes, plus new string translations (English, German, French). Eager-load user, limited episode previews, and first gallery image to reduce N+1 queries. Cache aggregate statistics for 10 minutes to improve performance on high-traffic pages.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Playlist;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
@@ -54,11 +55,30 @@ class Playlists extends Component
|
||||
->withCount('episodes')
|
||||
->having('episodes_count', '>', 1)
|
||||
->when($this->search != '', fn ($query) => $query->where('name', 'like', '%'.$this->search.'%'))
|
||||
->with([
|
||||
'user',
|
||||
'episodes' => fn ($query) => $query->orderBy('position')->limit(4),
|
||||
'episodes.episode.gallery' => fn ($query) => $query->limit(1),
|
||||
])
|
||||
->orderBy($orderby, $orderdirection)
|
||||
->paginate($this->pagination);
|
||||
|
||||
$stats = Cache::remember('publicPlaylistStats', 600, function () {
|
||||
$playlists = Playlist::where('is_private', 0)
|
||||
->withCount('episodes')
|
||||
->having('episodes_count', '>', 1)
|
||||
->get();
|
||||
|
||||
return [
|
||||
'playlists' => $playlists->count(),
|
||||
'episodes' => $playlists->sum('episodes_count'),
|
||||
];
|
||||
});
|
||||
|
||||
return view('livewire.playlists', [
|
||||
'playlists' => $playlists,
|
||||
'totalPlaylists' => $stats['playlists'],
|
||||
'totalEpisodes' => $stats['episodes'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
return [
|
||||
'no-playlist-found' => 'Keine Playlisten gefunden!',
|
||||
'hero-subtitle' => 'Kuratierte Episodensammlungen von der Community erstellt.',
|
||||
'by' => 'von',
|
||||
'playlists-count' => 'Playlisten',
|
||||
'clear-search' => 'Suche löschen',
|
||||
'personal-playlists' => 'Persönliche Playlisten',
|
||||
'create-on-personal-page' => 'Du kannst einen in deiner persönlichen Playlisten Seite erstellen.',
|
||||
'play' => 'Abspielen',
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
return [
|
||||
'no-playlist-found' => 'No Playlist found!',
|
||||
'hero-subtitle' => 'Curated episode collections made by the community.',
|
||||
'by' => 'by',
|
||||
'playlists-count' => 'Playlists',
|
||||
'clear-search' => 'Clear search',
|
||||
'personal-playlists' => 'Personal Playlists',
|
||||
'create-on-personal-page' => 'You can create one in your personal playlists page.',
|
||||
'play' => 'Play',
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
return [
|
||||
'no-playlist-found' => 'Aucune playlist n\'a été trouvée!',
|
||||
'hero-subtitle' => 'Collections d\'épisodes organisées par la communauté.',
|
||||
'by' => 'par',
|
||||
'playlists-count' => 'Playlists',
|
||||
'clear-search' => 'Effacer la recherche',
|
||||
'personal-playlists' => 'Playlists personnelles',
|
||||
'create-on-personal-page' => 'Vous pouvez en créer une dans votre page de playlists personnelles.',
|
||||
'play' => 'Lire',
|
||||
|
||||
@@ -1,27 +1,42 @@
|
||||
<div>
|
||||
<!-- Search -->
|
||||
<div class="p-4 mx-3 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="live-search"
|
||||
class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
|
||||
<div class="relative right-2 left-0 sm:left-2 transition-all">
|
||||
<div class="absolute inset-y-0 left-2 flex items-center pl-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||
</svg>
|
||||
<!-- Hero -->
|
||||
<section
|
||||
class="relative overflow-hidden rounded-3xl border border-neutral-200/70 bg-white/40 px-6 py-10 shadow-xl backdrop-blur-lg sm:px-10 sm:py-12 dark:border-neutral-800 dark:bg-neutral-950/40">
|
||||
<div class="pointer-events-none absolute -right-24 -top-24 h-72 w-72 rounded-full bg-rose-600/20 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute -bottom-24 -left-24 h-72 w-72 rounded-full bg-neutral-500/20 blur-3xl dark:bg-rose-900/10"></div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<h1 class="text-3xl font-bold text-neutral-900 sm:text-4xl dark:text-white">
|
||||
{{ __('nav.public-playlists') }}
|
||||
</h1>
|
||||
<p class="mt-2 text-neutral-600 dark:text-neutral-400">
|
||||
{{ __('playlist.hero-subtitle') }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full border border-neutral-200/70 bg-white/60 px-4 py-2 text-sm font-semibold text-neutral-800 shadow-sm backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-neutral-100">
|
||||
<i class="fa-solid fa-rectangle-list text-rose-600"></i>
|
||||
{{ number_format($totalPlaylists) }} {{ __('playlist.playlists-count') }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full border border-neutral-200/70 bg-white/60 px-4 py-2 text-sm font-semibold text-neutral-800 shadow-sm backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-neutral-100">
|
||||
<i class="fa-solid fa-film text-rose-600"></i>
|
||||
{{ number_format($totalEpisodes) }} {{ __('home.episodes') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="relative">
|
||||
<label for="playlist-search" class="sr-only">Search</label>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-4 flex items-center">
|
||||
<i class="fa-solid fa-magnifying-glass text-neutral-400"></i>
|
||||
</div>
|
||||
|
||||
<input wire:model.live.debounce.600ms="search" type="search" id="playlist-search"
|
||||
class="block w-full p-4 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900"
|
||||
placeholder="Search Playlist...">
|
||||
|
||||
<div class="absolute right-0 top-[11px]" wire:loading>
|
||||
<svg aria-hidden="true"
|
||||
class="inline w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-pink-600"
|
||||
viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<input wire:model.live.debounce.600ms="search" type="search" id="playlist-search" placeholder="Search Playlist..."
|
||||
class="w-full rounded-full border border-neutral-200/70 bg-white/60 py-3 pl-11 pr-12 text-sm text-neutral-900 placeholder-neutral-400 shadow-sm backdrop-blur transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/30 dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-white dark:placeholder-neutral-500">
|
||||
<div class="absolute inset-y-0 right-4 flex items-center" wire:loading>
|
||||
<svg aria-hidden="true" class="h-5 w-5 animate-spin text-neutral-400" viewBox="0 0 100 101" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor" />
|
||||
@@ -31,53 +46,165 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ordering -->
|
||||
<div class="relative right-2 left-0 sm:left-2 transition-all">
|
||||
<div class="absolute inset-y-0 left-2 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fa-solid fa-sort text-gray-500 dark:text-gray-400"></i>
|
||||
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-4 flex items-center">
|
||||
<i class="fa-solid fa-sort text-neutral-400"></i>
|
||||
</div>
|
||||
<select wire:model.live="order"
|
||||
class="w-full appearance-none rounded-full border border-neutral-200/70 bg-white/60 py-3 pl-11 pr-10 text-sm text-neutral-900 shadow-sm backdrop-blur transition focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-500/30 dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-white">
|
||||
<option value="az">A-Z</option>
|
||||
<option value="za">Z-A</option>
|
||||
<option value="episode-count">Episode count</option>
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
</select>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-4 flex items-center">
|
||||
<i class="fa-solid fa-chevron-down text-neutral-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
<select wire:model.live="order"
|
||||
class="block w-full p-4 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900">
|
||||
<option value="az">A-Z</option>
|
||||
<option value="za">Z-A</option>
|
||||
<option value="episode-count">Episode count</option>
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="mt-8" wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage">
|
||||
<div wire:loading.grid
|
||||
class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
@for ($i = 0; $i < 8; $i++)
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl border border-neutral-200/70 bg-white/60 shadow-md backdrop-blur-lg dark:border-neutral-800 dark:bg-neutral-950/50">
|
||||
<div class="relative aspect-video overflow-hidden">
|
||||
<div class="h-full w-full animate-pulse bg-neutral-200 dark:bg-neutral-800">
|
||||
<div class="shimmer-overlay h-full w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3 p-5">
|
||||
<div class="h-4 w-3/4 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800">
|
||||
<div class="shimmer-overlay h-full w-full"></div>
|
||||
</div>
|
||||
<div class="h-3 w-1/2 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800">
|
||||
<div class="shimmer-overlay h-full w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endfor
|
||||
</div>
|
||||
|
||||
<div wire:loading.remove>
|
||||
@if ($playlists->isEmpty())
|
||||
<div
|
||||
class="rounded-3xl border border-neutral-200/70 bg-white/40 px-6 py-16 text-center shadow-xl backdrop-blur-lg dark:border-neutral-800 dark:bg-neutral-950/40">
|
||||
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-rose-600/10 text-rose-600">
|
||||
<i class="fa-solid fa-rectangle-list text-2xl"></i>
|
||||
</div>
|
||||
<p class="mt-4 text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
{{ __('playlist.no-playlist-found') }}
|
||||
</p>
|
||||
@if ($search !== '')
|
||||
<button type="button" wire:click="$set('search', '')"
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-full bg-rose-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-rose-700">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
{{ __('playlist.clear-search') }}
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
@foreach ($playlists as $playlist)
|
||||
@php
|
||||
$covers = $playlist->episodes
|
||||
->take(4)
|
||||
->map(fn ($pe) => $pe->episode?->gallery->first()?->thumbnail_url)
|
||||
->filter()
|
||||
->values();
|
||||
@endphp
|
||||
|
||||
<div wire:key="playlist-{{ $playlist->id }}"
|
||||
class="group flex flex-col overflow-hidden rounded-2xl border border-neutral-200/70 bg-white/60 shadow-md backdrop-blur-lg transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl hover:shadow-black/30 dark:border-neutral-800 dark:bg-neutral-950/50 dark:hover:border-neutral-700">
|
||||
<a href="{{ route('playlist.show', $playlist->id) }}"
|
||||
class="relative block aspect-video overflow-hidden bg-gradient-to-br from-neutral-800 via-neutral-900 to-neutral-950">
|
||||
@if ($covers->isEmpty())
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
<i class="fa-solid fa-rectangle-list text-4xl text-neutral-500"></i>
|
||||
</div>
|
||||
@elseif ($covers->count() === 1)
|
||||
<img src="{{ $covers[0] }}" alt="{{ $playlist->name }}" loading="lazy"
|
||||
class="h-full w-full object-cover">
|
||||
@elseif ($covers->count() === 2)
|
||||
<div class="grid h-full w-full grid-cols-2">
|
||||
@foreach ($covers as $cover)
|
||||
<img src="{{ $cover }}" alt="" loading="lazy"
|
||||
class="h-full w-full object-cover">
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif ($covers->count() === 3)
|
||||
<div class="grid h-full w-full grid-cols-2 grid-rows-2">
|
||||
<img src="{{ $covers[0] }}" alt="" loading="lazy"
|
||||
class="col-span-2 h-full w-full object-cover">
|
||||
<img src="{{ $covers[1] }}" alt="" loading="lazy"
|
||||
class="h-full w-full object-cover">
|
||||
<img src="{{ $covers[2] }}" alt="" loading="lazy"
|
||||
class="h-full w-full object-cover">
|
||||
</div>
|
||||
@else
|
||||
<div class="grid h-full w-full grid-cols-2 grid-rows-2">
|
||||
@foreach ($covers->take(4) as $cover)
|
||||
<img src="{{ $cover }}" alt="" loading="lazy"
|
||||
class="h-full w-full object-cover">
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<span
|
||||
class="absolute left-3 top-3 inline-flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-xs font-semibold text-white backdrop-blur">
|
||||
<i class="fa-solid fa-clapperboard"></i>
|
||||
{{ $playlist->episodes_count }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 transition duration-300 group-hover:opacity-100">
|
||||
<span
|
||||
class="flex h-14 w-14 items-center justify-center rounded-full bg-rose-600 shadow-lg transition-transform duration-300 group-hover:scale-110">
|
||||
<i class="fa-solid fa-play pl-1 text-lg text-white"></i>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="flex flex-1 flex-col p-5">
|
||||
<h3 class="text-base font-semibold text-neutral-900 line-clamp-2 dark:text-neutral-50">
|
||||
<a href="{{ route('playlist.show', $playlist->id) }}">{{ $playlist->name }}</a>
|
||||
</h3>
|
||||
|
||||
<div class="mt-2 flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<img src="{{ $playlist->user?->getAvatar() ?? asset('images/default-avatar.webp') }}"
|
||||
alt="" class="h-5 w-5 rounded-full ring-1 ring-neutral-200 dark:ring-neutral-700">
|
||||
<span class="truncate">{{ $playlist->user?->name }}</span>
|
||||
<span>·</span>
|
||||
<span class="shrink-0">{{ $playlist->created_at->diffForHumans() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{{ $playlist->episodes_count }} {{ __('home.episodes') }}
|
||||
</span>
|
||||
<a href="{{ route('hentai.index', ['title' => $playlist->episodes->first()->episode->slug, 'playlist' => $playlist->id]) }}"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-rose-600 to-rose-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:from-rose-700 hover:to-rose-800">
|
||||
<i class="fa-solid fa-play"></i>
|
||||
{{ __('playlist.play') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-cols-1 sm:grid md:grid-cols-3" wire:keydown.right.window="nextPage"
|
||||
wire:keydown.left.window="previousPage">
|
||||
|
||||
@foreach ($playlists as $playlist)
|
||||
<div wire:key="playlist-{{ $playlist->id }}"
|
||||
class="mx-3 mt-6 flex flex-col rounded-lg bg-white shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:bg-neutral-950/50 backdrop-blur-lg sm:shrink-0 sm:grow sm:basis-0 z-10">
|
||||
<a href="{{ route('playlist.show', $playlist->id) }}">
|
||||
@php
|
||||
$pe = \App\Models\PlaylistEpisode::where('playlist_id', $playlist->id)
|
||||
->orderBy('position', 'desc')
|
||||
->first();
|
||||
@endphp
|
||||
<img class="rounded-t-lg aspect-video" src="{{ $pe->episode->gallery->first()->thumbnail_url }}"
|
||||
alt="Hollywood Sign on The Hill" />
|
||||
</a>
|
||||
<div class="p-6">
|
||||
<h5 class="mb-2 text-xl font-medium leading-tight text-neutral-800 dark:text-neutral-50">
|
||||
{{ $playlist->name }}
|
||||
</h5>
|
||||
<p class="mb-2 text-sm leading-tight text-neutral-800 dark:text-neutral-50">
|
||||
{{ $playlist->episodes_count }} {{ __('home.episodes') }}
|
||||
<a href="{{ route('hentai.index', ['title' => $playlist->episodes->first()->episode->slug, 'playlist' => $playlist->id]) }}"
|
||||
class="cursor-pointer float-right text-white bg-rose-700 hover:bg-rose-800 focus:ring-4 focus:outline-none focus:ring-rose-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-rose-600 dark:hover:bg-rose-700 dark:focus:ring-rose-800">{{ __('playlist.play') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="mt-10 mb-10">
|
||||
{{ $playlists->links('pagination::tailwind') }}
|
||||
</div>
|
||||
@if ($playlists->hasPages())
|
||||
<div class="mt-10 mb-10">
|
||||
{{ $playlists->links('pagination::tailwind') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight z-10">
|
||||
{{ __('nav.public-playlists') }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="max-w-6xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="mx-auto pt-6 sm:px-6 lg:px-8 max-w-[100%] xl:max-w-[95%] 2xl:max-w-[85%]">
|
||||
@include('partials.background')
|
||||
@livewire('playlists')
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user