Update playlist design
This commit is contained in:
@@ -2,10 +2,11 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Episode;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\PlaylistEpisode;
|
||||
use App\Services\PlaylistService;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
@@ -18,14 +19,13 @@ class PlaylistOverview extends Component
|
||||
protected PlaylistService $playlistService;
|
||||
|
||||
#[Url(history: true)]
|
||||
public $search;
|
||||
public string $search = '';
|
||||
|
||||
public int $pagination = 25;
|
||||
#[Url(history: true)]
|
||||
public int $perPage = 25;
|
||||
|
||||
public Playlist $playlist;
|
||||
|
||||
public Collection $playlistEpisodes;
|
||||
|
||||
public bool $editingName = false;
|
||||
|
||||
public string $editingPlaylistName = '';
|
||||
@@ -37,35 +37,56 @@ class PlaylistOverview extends Component
|
||||
|
||||
public function mount($playlist_id)
|
||||
{
|
||||
$this->playlist = Playlist::with(['episodes.episode'])->findOrFail($playlist_id);
|
||||
$this->playlist = Playlist::withCount('episodes')->with('user')->findOrFail($playlist_id);
|
||||
|
||||
// Set position if null
|
||||
$this->playlist->episodes->each(function ($item, $index) {
|
||||
if ($item->position === null) {
|
||||
$item->position = $index + 1;
|
||||
$item->save();
|
||||
}
|
||||
});
|
||||
$this->sanitizePerPage();
|
||||
|
||||
$this->refreshEpisodes();
|
||||
$this->repairNullPositions();
|
||||
}
|
||||
|
||||
public function refreshEpisodes()
|
||||
public function updatingSearch(): void
|
||||
{
|
||||
$this->playlistEpisodes = $this->playlist->episodes()->orderBy('position')->with('episode')->get();
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatingPerPage(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function getEpisodesProperty(): LengthAwarePaginator
|
||||
{
|
||||
$this->sanitizePerPage();
|
||||
|
||||
return PlaylistEpisode::query()
|
||||
->where('playlist_id', $this->playlist->id)
|
||||
->when($this->search !== '', fn ($query) => $query->whereHas('episode', fn ($episode) => $episode
|
||||
->where('title', 'like', '%'.$this->search.'%')
|
||||
->orWhere('title_jpn', 'like', '%'.$this->search.'%')))
|
||||
->orderBy('position')
|
||||
->with(['episode.gallery' => fn ($gallery) => $gallery->orderBy('id')->limit(1)])
|
||||
->paginate($this->perPage);
|
||||
}
|
||||
|
||||
public function getFirstEpisodeProperty(): ?Episode
|
||||
{
|
||||
return PlaylistEpisode::where('playlist_id', $this->playlist->id)
|
||||
->orderBy('position')
|
||||
->first()?->episode;
|
||||
}
|
||||
|
||||
public function moveUp($episodeId)
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Auth::user()->id !== $this->playlist->user->id) {
|
||||
if (! $this->isOwner()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$episode = PlaylistEpisode::find($episodeId);
|
||||
|
||||
if (! $episode) {
|
||||
return;
|
||||
}
|
||||
|
||||
$above = PlaylistEpisode::where('playlist_id', $episode->playlist_id)
|
||||
->where('position', '<', $episode->position)
|
||||
->orderBy('position', 'desc')
|
||||
@@ -74,21 +95,20 @@ class PlaylistOverview extends Component
|
||||
if ($above) {
|
||||
$this->playlistService->swapPositions($episode, $above);
|
||||
}
|
||||
|
||||
$this->refreshEpisodes();
|
||||
}
|
||||
|
||||
public function moveDown($episodeId)
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Auth::user()->id !== $this->playlist->user->id) {
|
||||
if (! $this->isOwner()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$episode = PlaylistEpisode::find($episodeId);
|
||||
|
||||
if (! $episode) {
|
||||
return;
|
||||
}
|
||||
|
||||
$below = PlaylistEpisode::where('playlist_id', $episode->playlist_id)
|
||||
->where('position', '>', $episode->position)
|
||||
->orderBy('position')
|
||||
@@ -97,28 +117,28 @@ class PlaylistOverview extends Component
|
||||
if ($below) {
|
||||
$this->playlistService->swapPositions($episode, $below);
|
||||
}
|
||||
|
||||
$this->refreshEpisodes();
|
||||
}
|
||||
|
||||
public function remove($episodeId)
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Auth::user()->id !== $this->playlist->user->id) {
|
||||
if (! $this->isOwner()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlaylistEpisode::find($episodeId)?->delete();
|
||||
$this->playlistService->reorderPositions($this->playlist);
|
||||
$this->refreshEpisodes();
|
||||
$this->playlist->loadCount('episodes');
|
||||
|
||||
$lastPage = max(1, (int) ceil(PlaylistEpisode::where('playlist_id', $this->playlist->id)->count() / $this->perPage));
|
||||
|
||||
if ($this->getPage() > $lastPage) {
|
||||
$this->setPage($lastPage);
|
||||
}
|
||||
}
|
||||
|
||||
public function editName()
|
||||
{
|
||||
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) {
|
||||
if (! $this->isOwner()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -134,7 +154,7 @@ class PlaylistOverview extends Component
|
||||
|
||||
public function updateName()
|
||||
{
|
||||
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) {
|
||||
if (! $this->isOwner()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -152,7 +172,7 @@ class PlaylistOverview extends Component
|
||||
|
||||
public function toggleVisibility()
|
||||
{
|
||||
if (! Auth::check() || Auth::user()->id !== $this->playlist->user->id) {
|
||||
if (! $this->isOwner()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -160,13 +180,39 @@ class PlaylistOverview extends Component
|
||||
'is_private' => ! $this->playlist->is_private,
|
||||
]);
|
||||
|
||||
$this->playlist->refresh();
|
||||
$this->playlist->loadCount('episodes');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.playlist-overview', [
|
||||
'query' => $this->search,
|
||||
'episodes' => $this->episodes,
|
||||
'firstEpisode' => $this->firstEpisode,
|
||||
'isOwner' => $this->isOwner(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function isOwner(): bool
|
||||
{
|
||||
return Auth::check() && Auth::user()->id === $this->playlist->user_id;
|
||||
}
|
||||
|
||||
private function sanitizePerPage(): void
|
||||
{
|
||||
if (! in_array($this->perPage, [25, 50, 100], true)) {
|
||||
$this->perPage = 25;
|
||||
}
|
||||
}
|
||||
|
||||
private function repairNullPositions(): void
|
||||
{
|
||||
if (! PlaylistEpisode::where('playlist_id', $this->playlist->id)->whereNull('position')->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlaylistEpisode::where('playlist_id', $this->playlist->id)
|
||||
->orderBy('position')->orderBy('id')
|
||||
->get()
|
||||
->each(fn ($playlistEpisode, $index) => $playlistEpisode->update(['position' => $index + 1]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Gallery extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $table = 'gallery';
|
||||
|
||||
/**
|
||||
* The attributes that aren't mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* Belongs To Episode.
|
||||
*/
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Playlist extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PlaylistEpisode extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Indicates If The Model Should Be Timestamped.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Episode;
|
||||
use App\Models\Gallery;
|
||||
use App\Models\Hentai;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Gallery>
|
||||
*/
|
||||
class GalleryFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'hentai_id' => Hentai::factory(),
|
||||
'episode_id' => Episode::factory(),
|
||||
'image_url' => $this->faker->url(),
|
||||
'thumbnail_url' => $this->faker->url(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Episode;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\PlaylistEpisode;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<PlaylistEpisode>
|
||||
*/
|
||||
class PlaylistEpisodeFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'playlist_id' => Playlist::factory(),
|
||||
'episode_id' => Episode::factory(),
|
||||
'position' => $this->faker->numberBetween(1, 1000),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Playlist;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Playlist>
|
||||
*/
|
||||
class PlaylistFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'name' => $this->faker->word(),
|
||||
'is_private' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,11 @@ return [
|
||||
'create-on-personal-page' => 'Du kannst einen in deiner persönlichen Playlisten Seite erstellen.',
|
||||
'play' => 'Abspielen',
|
||||
'playlist' => 'Playlist',
|
||||
'search-episodes' => 'Episoden durchsuchen',
|
||||
'per-page' => 'Pro Seite',
|
||||
'episodes' => 'Episoden',
|
||||
'empty-playlist' => 'Diese Playlist ist leer.',
|
||||
'no-matches' => 'Keine Episoden entsprechen deiner Suche.',
|
||||
'watched' => 'Gesehen',
|
||||
'remove' => 'Entfernen',
|
||||
];
|
||||
|
||||
@@ -6,4 +6,11 @@ return [
|
||||
'create-on-personal-page' => 'You can create one in your personal playlists page.',
|
||||
'play' => 'Play',
|
||||
'playlist' => 'Playlist',
|
||||
'search-episodes' => 'Search episodes',
|
||||
'per-page' => 'Per page',
|
||||
'episodes' => 'Episodes',
|
||||
'empty-playlist' => 'This playlist is empty.',
|
||||
'no-matches' => 'No episodes match your search.',
|
||||
'watched' => 'Watched',
|
||||
'remove' => 'Remove',
|
||||
];
|
||||
|
||||
@@ -6,4 +6,11 @@ return [
|
||||
'create-on-personal-page' => 'Vous pouvez en créer une dans votre page de playlists personnelles.',
|
||||
'play' => 'Lire',
|
||||
'playlist' => 'Playlist',
|
||||
'search-episodes' => 'Rechercher des épisodes',
|
||||
'per-page' => 'Par page',
|
||||
'episodes' => 'Épisodes',
|
||||
'empty-playlist' => 'Cette playlist est vide.',
|
||||
'no-matches' => 'Aucun épisode ne correspond à votre recherche.',
|
||||
'watched' => 'Vu',
|
||||
'remove' => 'Supprimer',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
@props([
|
||||
'playlistEpisode',
|
||||
'isOwner' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$episode = $playlistEpisode->episode;
|
||||
@endphp
|
||||
|
||||
<div wire:key="playlist-episode-{{ $playlistEpisode->id }}"
|
||||
class="group flex items-center gap-3 sm:gap-4 rounded-xl border border-neutral-200/70 bg-white p-2.5 sm:p-3 shadow-sm transition hover:border-rose-400/40 hover:bg-rose-50 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-900">
|
||||
<div
|
||||
class="h-8 w-8 shrink-0 rounded-full bg-neutral-100 text-sm font-bold text-neutral-500 flex items-center justify-center dark:bg-neutral-800 dark:text-neutral-400">
|
||||
{{ $playlistEpisode->position }}
|
||||
</div>
|
||||
|
||||
<a href="{{ route('hentai.index', ['title' => $episode->slug]) }}" class="shrink-0">
|
||||
<img src="{{ $episode->gallery->first()?->thumbnail_url }}"
|
||||
alt="{{ $episode->title }} - {{ $episode->episode }}" loading="lazy" width="160"
|
||||
class="aspect-video w-24 sm:w-36 rounded-lg object-cover transition-transform duration-300 group-hover:scale-105">
|
||||
</a>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href="{{ route('hentai.index', ['title' => $episode->slug]) }}"
|
||||
class="block truncate font-bold text-neutral-900 transition hover:text-rose-500 dark:text-white dark:hover:text-rose-400">
|
||||
{{ $episode->title }} - {{ $episode->episode }}
|
||||
</a>
|
||||
|
||||
@if ($episode->title_jpn)
|
||||
<p class="truncate text-sm text-neutral-500 dark:text-neutral-400">{{ $episode->title_jpn }}</p>
|
||||
@endif
|
||||
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px] font-semibold">
|
||||
<span class="rounded-full bg-black/70 px-2 py-0.5 text-white ring-1 ring-white/10 dark:bg-neutral-700">
|
||||
{{ $episode->getResolution() }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400">
|
||||
<i class="fa-regular fa-eye"></i> {{ $episode->viewCountFormatted() }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400">
|
||||
<i class="fa-regular fa-heart"></i> {{ $episode->likeCount() }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400">
|
||||
<i class="fa-regular fa-comment"></i> {{ $episode->commentCount() }}
|
||||
</span>
|
||||
@auth
|
||||
@if ($episode->userWatched(auth()->id()))
|
||||
<span
|
||||
class="flex items-center gap-1 rounded-full bg-emerald-800/40 px-2 py-0.5 text-emerald-300 ring-1 ring-emerald-500/30">
|
||||
<i class="fa-solid fa-eye"></i> {{ __('playlist.watched') }}
|
||||
</span>
|
||||
@endif
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($isOwner)
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button type="button" wire:click="moveUp({{ $playlistEpisode->id }})"
|
||||
class="h-8 w-8 rounded-lg text-neutral-500 transition hover:bg-neutral-100 hover:text-rose-500 dark:text-neutral-400 dark:hover:bg-neutral-800">
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
</button>
|
||||
<button type="button" wire:click="moveDown({{ $playlistEpisode->id }})"
|
||||
class="h-8 w-8 rounded-lg text-neutral-500 transition hover:bg-neutral-100 hover:text-rose-500 dark:text-neutral-400 dark:hover:bg-neutral-800">
|
||||
<i class="fa-solid fa-arrow-down"></i>
|
||||
</button>
|
||||
<button type="button" wire:click="remove({{ $playlistEpisode->id }})"
|
||||
class="h-8 w-8 rounded-lg text-red-500 transition hover:bg-red-50 dark:hover:bg-red-950/40">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -1,57 +1,58 @@
|
||||
<div>
|
||||
<div wire:keydown.right.window="nextPage" wire:keydown.left.window="previousPage"
|
||||
class="text-gray-900 dark:text-white">
|
||||
<div
|
||||
class="relative pt-5 mx-auto sm:px-6 lg:px-8 space-y-6 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex text-sm font-light bg-neutral-950/50 backdrop-blur-lg rounded-lg p-10 gap-2">
|
||||
<div>
|
||||
<img class="relative w-24 h-24 flex-none rounded-full shadow-lg"
|
||||
src="{{ $playlist->user->getAvatar() }}">
|
||||
</div>
|
||||
<div class="flex flex-col justify-center flex-1 pl-4">
|
||||
<div
|
||||
class="flex flex-col items-center gap-6 rounded-2xl border border-neutral-200/70 bg-white p-6 shadow-sm sm:flex-row sm:items-center sm:gap-8 sm:p-8 dark:border-neutral-800 dark:bg-neutral-950">
|
||||
<img class="h-24 w-24 shrink-0 rounded-full shadow-lg ring-2 ring-rose-500/20"
|
||||
src="{{ $playlist->user->getAvatar() }}">
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col text-center sm:text-left">
|
||||
@if ($editingName)
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 mb-1 sm:justify-start">
|
||||
<input
|
||||
type="text"
|
||||
wire:model="editingPlaylistName"
|
||||
maxlength="30"
|
||||
class="rounded-lg border border-neutral-400 bg-neutral-800 px-3 py-1.5 text-xl font-bold text-white focus:border-rose-500 focus:outline-none focus:ring-1 focus:ring-rose-500"
|
||||
class="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-xl font-bold text-neutral-900 focus:border-rose-500 focus:outline-none focus:ring-1 focus:ring-rose-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white"
|
||||
/>
|
||||
<button wire:click="updateName" class="rounded-lg bg-rose-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-rose-700">
|
||||
Save
|
||||
</button>
|
||||
<button wire:click="cancelEditName" class="rounded-lg border border-neutral-500 px-3 py-1.5 text-sm text-neutral-300 transition hover:bg-neutral-700">
|
||||
<button wire:click="cancelEditName" class="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm text-neutral-600 transition hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@error('editingPlaylistName')
|
||||
<p class="text-rose-400 text-sm mb-1">{{ $message }}</p>
|
||||
<p class="text-rose-500 text-sm mb-1">{{ $message }}</p>
|
||||
@enderror
|
||||
@else
|
||||
<h1 class="font-bold text-3xl">
|
||||
{{ $playlist->name }}
|
||||
<h1 class="flex items-center justify-center gap-1 text-3xl font-bold text-neutral-900 sm:justify-start dark:text-white">
|
||||
<span class="truncate">{{ $playlist->name }}</span>
|
||||
@auth
|
||||
@if (Auth::id() === $playlist->user->id)
|
||||
<button wire:click="editName" class="ml-2 text-xl text-neutral-400 transition hover:text-white" title="Edit playlist name">
|
||||
<button wire:click="editName" class="ml-1 text-xl text-neutral-400 transition hover:text-rose-500" title="Edit playlist name">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</button>
|
||||
@endif
|
||||
@endauth
|
||||
</h1>
|
||||
@endif
|
||||
<p class="font-light text-lg text-neutral-200">Episodes: {{ count($playlistEpisodes) }}</p>
|
||||
<p class="font-light text-lg text-neutral-200">
|
||||
<p class="mt-1 text-lg font-light text-neutral-500 dark:text-neutral-300">{{ __('playlist.episodes') }}: {{ $playlist->episodes_count }}</p>
|
||||
<p class="mt-0.5 text-lg font-light text-neutral-500 dark:text-neutral-300">
|
||||
Creator: {{ $playlist->user->name }}
|
||||
@auth
|
||||
@if (Auth::id() === $playlist->user->id)
|
||||
<span class="ml-3">
|
||||
<button wire:click="toggleVisibility" class="cursor-pointer rounded-full px-3 py-0.5 text-xs font-semibold transition {{ $playlist->is_private ? 'bg-neutral-600 text-neutral-300 hover:bg-neutral-500' : 'bg-green-600 text-white hover:bg-green-500' }}">
|
||||
<button wire:click="toggleVisibility" class="cursor-pointer rounded-full px-3 py-0.5 text-xs font-semibold transition {{ $playlist->is_private ? 'bg-neutral-200 text-neutral-700 hover:bg-neutral-300 dark:bg-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-600' : 'bg-green-600 text-white hover:bg-green-500' }}">
|
||||
<i class="fa-solid {{ $playlist->is_private ? 'fa-lock' : 'fa-earth-americas' }} mr-1"></i>
|
||||
{{ $playlist->is_private ? 'Private' : 'Public' }}
|
||||
</button>
|
||||
</span>
|
||||
@else
|
||||
<span class="ml-3 rounded-full px-3 py-0.5 text-xs font-semibold {{ $playlist->is_private ? 'bg-neutral-600 text-neutral-300' : 'bg-green-600 text-white' }}">
|
||||
<span class="ml-3 rounded-full px-3 py-0.5 text-xs font-semibold {{ $playlist->is_private ? 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200' : 'bg-green-600 text-white' }}">
|
||||
<i class="fa-solid {{ $playlist->is_private ? 'fa-lock' : 'fa-earth-americas' }} mr-1"></i>
|
||||
{{ $playlist->is_private ? 'Private' : 'Public' }}
|
||||
</span>
|
||||
@@ -59,88 +60,86 @@
|
||||
@endauth
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center pl-4">
|
||||
<div class="flex justify-end">
|
||||
@php $episode = $playlistEpisodes->first()?->episode; @endphp
|
||||
@if(isset($episode))
|
||||
<a href="{{ route('hentai.index', ['title' => $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>
|
||||
@else
|
||||
<a class="cursor-not-allowed float-right text-white bg-neutral-700 focus:ring-4 focus:outline-none focus:ring-neutral-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-neutral-600 dark:focus:ring-neutral-800">{{ __('playlist.play') }}</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
@if ($firstEpisode)
|
||||
<a href="{{ route('hentai.index', ['title' => $firstEpisode->slug, 'playlist' => $playlist->id]) }}"
|
||||
class="inline-flex items-center gap-2 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">
|
||||
<i class="fa-solid fa-play text-xs"></i> {{ __('playlist.play') }}
|
||||
</a>
|
||||
@else
|
||||
<a class="inline-flex cursor-not-allowed items-center gap-2 text-neutral-500 bg-neutral-200 font-medium rounded-lg text-sm px-4 py-2 dark:text-neutral-300 dark:bg-neutral-700">
|
||||
<i class="fa-solid fa-play text-xs"></i> {{ __('playlist.play') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@forelse($playlistEpisodes as $playlistEpisode)
|
||||
@php $episode = $playlistEpisode->episode; @endphp
|
||||
<div wire:key="playlist-episode-{{ $playlistEpisode->id }}"
|
||||
class="flex justify-between items-center rounded-lg hover:bg-black border border-neutral-950 bg-neutral-950/50 backdrop-blur-lg transition !mt-1 gap-2">
|
||||
<div class="flex pl-5 pr-5 w-10">
|
||||
{{ $playlistEpisode->position }}
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div class="relative flex-1">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
|
||||
<svg class="h-5 w-5 text-neutral-400" 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>
|
||||
</div>
|
||||
<div class="flex-[2] hidden md:block">
|
||||
<div class="relative p-1 w-full md:p-2">
|
||||
<a href="{{ route('hentai.index', ['title' => $episode->slug]) }}">
|
||||
<img alt="{{ $episode->title }} - {{ $episode->episode }}" loading="lazy" width="1000"
|
||||
class="block object-cover object-center relative z-20 rounded-lg aspect-video"
|
||||
src="{{ $episode->gallery->first()->thumbnail_url }}">
|
||||
|
||||
@guest
|
||||
<p
|
||||
class="absolute left-2 bottom-2 bg-rose-700/70 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30 collapse md:visible">
|
||||
<i class="fa-regular fa-eye"></i> {{ $episode->viewCountFormatted() }} <i
|
||||
class="fa-regular fa-heart"></i>
|
||||
{{ $episode->likeCount() }} <i class="fa-regular fa-comment"></i>
|
||||
{{ $episode->commentCount() }}
|
||||
</p>
|
||||
@endguest
|
||||
<input
|
||||
wire:model.live.debounce.500ms="search"
|
||||
type="search"
|
||||
placeholder="{{ __('playlist.search-episodes') }}"
|
||||
class="w-full rounded-xl border border-neutral-300 bg-white py-2.5 pl-12 pr-12 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-4 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
|
||||
@auth
|
||||
@if ($episode->userWatched(auth()->user()->id))
|
||||
<p
|
||||
class="absolute left-2 bottom-2 bg-green-600/80 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
<i class="fa-regular fa-eye"></i> {{ $episode->viewCountFormatted() }} <i
|
||||
class="fa-regular fa-heart"></i> {{ $episode->likeCount() }} <i
|
||||
class="fa-regular fa-comment"></i>
|
||||
{{ $episode->commentCount() }}
|
||||
</p>
|
||||
@else
|
||||
<p
|
||||
class="absolute left-2 bottom-2 bg-rose-700/70 !text-white rounded-bl-lg rounded-tr-lg p-1 pr-2 pl-2 font-semibold text-sm z-30">
|
||||
<i class="fa-regular fa-eye"></i> {{ $episode->viewCountFormatted() }} <i
|
||||
class="fa-regular fa-heart"></i> {{ $episode->likeCount() }} <i
|
||||
class="fa-regular fa-comment"></i>
|
||||
{{ $episode->commentCount() }}
|
||||
</p>
|
||||
@endif
|
||||
@endauth
|
||||
</a>
|
||||
<div wire:loading.class="opacity-100" class="opacity-0">
|
||||
<div class="absolute inset-y-0 right-3 flex items-center">
|
||||
<svg class="h-5 w-5 animate-spin text-rose-500" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-90" fill="currentColor"
|
||||
d="M22 12a10 10 0 0 1-10 10V18a6 6 0 0 0 6-6h4Z">
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-[5]">
|
||||
<a href="{{ route('hentai.index', ['title' => $episode->slug]) }}"
|
||||
class="font-bold">{{ $episode->title }} - {{ $episode->episode }}</a>
|
||||
<br>
|
||||
<a href="{{ route('hentai.index', ['title' => $episode->slug]) }}">{{ $episode->title_jpn }}</a>
|
||||
</div>
|
||||
<div class="pr-5">
|
||||
@auth
|
||||
@if (Auth::user()->id === $playlist->user->id)
|
||||
<button class="pr-2" wire:click="moveUp({{ $playlistEpisode->id }})"><i
|
||||
class="fa-solid fa-arrow-up"></i></button>
|
||||
<button class="pr-2" wire:click="moveDown({{ $playlistEpisode->id }})"><i
|
||||
class="fa-solid fa-arrow-down"></i></button>
|
||||
<button wire:click="remove({{ $playlistEpisode->id }})" class="text-red-500"><i
|
||||
class="fa-solid fa-trash"></i></button>
|
||||
@endif
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="pt-6 text-2xl text-center">
|
||||
No results (╥﹏╥)
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<label for="playlistPerPage" class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{{ __('playlist.per-page') }}
|
||||
</label>
|
||||
<select
|
||||
wire:model.live="perPage"
|
||||
id="playlistPerPage"
|
||||
class="rounded-xl border border-neutral-300 bg-white py-2.5 pl-3 pr-8 text-sm text-neutral-900 shadow-sm transition focus:border-rose-500 focus:outline-none focus:ring-4 focus:ring-rose-500/20 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white"
|
||||
>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="space-y-2">
|
||||
@forelse($episodes as $playlistEpisode)
|
||||
@include('livewire.partials.playlist-episode-row', ['playlistEpisode' => $playlistEpisode, 'isOwner' => $isOwner])
|
||||
@empty
|
||||
<div
|
||||
class="rounded-2xl bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-12 text-center">
|
||||
<div class="inline-flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800 mb-4">
|
||||
<i class="fa-solid fa-clapperboard text-3xl text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ $search !== '' ? __('playlist.no-matches') : __('playlist.empty-playlist') }}
|
||||
</h3>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
@if ($episodes->hasPages())
|
||||
<div class="mt-6">{{ $episodes->links('pagination::tailwind') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Livewire;
|
||||
|
||||
use App\Livewire\PlaylistOverview;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Gallery;
|
||||
use App\Models\Hentai;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\PlaylistEpisode;
|
||||
use App\Models\Studios;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabaseState;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PlaylistOverviewTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* The repo's post-launch data-fix migrations (e.g. 2026_01_08_213625)
|
||||
* assume a populated production schema and fail on a fresh database, so
|
||||
* the "testing" database is provisioned from a production schema dump
|
||||
* instead of running migrate:fresh.
|
||||
*/
|
||||
protected function migrateDatabases(): void
|
||||
{
|
||||
RefreshDatabaseState::$migrated = true;
|
||||
}
|
||||
|
||||
private function makePlaylist(int $episodeCount = 1): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$playlist = Playlist::factory()->create(['user_id' => $user->id]);
|
||||
$hentai = Hentai::factory()->create();
|
||||
$studio = Studios::factory()->create();
|
||||
|
||||
for ($i = 1; $i <= $episodeCount; $i++) {
|
||||
$episode = Episode::factory()->create([
|
||||
'hentai_id' => $hentai->id,
|
||||
'studios_id' => $studio->id,
|
||||
'title' => sprintf('Episode %02d', $i),
|
||||
]);
|
||||
|
||||
Gallery::factory()->create([
|
||||
'hentai_id' => $hentai->id,
|
||||
'episode_id' => $episode->id,
|
||||
]);
|
||||
|
||||
PlaylistEpisode::factory()->create([
|
||||
'playlist_id' => $playlist->id,
|
||||
'episode_id' => $episode->id,
|
||||
'position' => $i,
|
||||
]);
|
||||
}
|
||||
|
||||
return [$user, $playlist];
|
||||
}
|
||||
|
||||
public function test_episodes_are_paginated(): void
|
||||
{
|
||||
[, $playlist] = $this->makePlaylist(30);
|
||||
|
||||
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
|
||||
->assertViewHas('episodes', fn ($episodes) => $episodes->total() === 30
|
||||
&& $episodes->count() === 25
|
||||
&& $episodes->hasPages())
|
||||
->assertSee('Episode 01')
|
||||
->assertSee('Episode 25')
|
||||
->assertDontSee('Episode 26');
|
||||
}
|
||||
|
||||
public function test_per_page_selector_changes_page_size(): void
|
||||
{
|
||||
[, $playlist] = $this->makePlaylist(30);
|
||||
|
||||
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
|
||||
->set('perPage', 100)
|
||||
->assertViewHas('episodes', fn ($episodes) => $episodes->total() === 30
|
||||
&& $episodes->count() === 30
|
||||
&& ! $episodes->hasPages())
|
||||
->assertSee('Episode 30');
|
||||
}
|
||||
|
||||
public function test_per_page_is_restricted_to_allowed_values(): void
|
||||
{
|
||||
[, $playlist] = $this->makePlaylist(30);
|
||||
|
||||
Livewire::withQueryParams(['perPage' => 999])
|
||||
->test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
|
||||
->assertSet('perPage', 25)
|
||||
->assertViewHas('episodes', fn ($episodes) => $episodes->perPage() === 25);
|
||||
}
|
||||
|
||||
public function test_search_filters_episodes_and_resets_page(): void
|
||||
{
|
||||
[, $playlist] = $this->makePlaylist(30);
|
||||
|
||||
$hentai = Hentai::factory()->create();
|
||||
$studio = Studios::factory()->create();
|
||||
$uniqueEpisode = Episode::factory()->create([
|
||||
'hentai_id' => $hentai->id,
|
||||
'studios_id' => $studio->id,
|
||||
'title' => 'ZebraUnicornSearch',
|
||||
]);
|
||||
|
||||
Gallery::factory()->create([
|
||||
'hentai_id' => $hentai->id,
|
||||
'episode_id' => $uniqueEpisode->id,
|
||||
]);
|
||||
|
||||
PlaylistEpisode::factory()->create([
|
||||
'playlist_id' => $playlist->id,
|
||||
'episode_id' => $uniqueEpisode->id,
|
||||
'position' => 31,
|
||||
]);
|
||||
|
||||
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
|
||||
->call('gotoPage', 2)
|
||||
->assertSet('paginators.page', 2)
|
||||
->set('search', 'ZebraUnicornSearch')
|
||||
->assertSet('paginators.page', 1)
|
||||
->assertSee('ZebraUnicornSearch')
|
||||
->assertDontSee('Episode 02');
|
||||
}
|
||||
|
||||
public function test_guest_sees_no_owner_controls(): void
|
||||
{
|
||||
[, $playlist] = $this->makePlaylist(3);
|
||||
|
||||
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
|
||||
->assertDontSee('wire:click="moveUp')
|
||||
->assertDontSee('wire:click="moveDown')
|
||||
->assertDontSee('wire:click="remove');
|
||||
}
|
||||
|
||||
public function test_only_owner_can_move_episodes(): void
|
||||
{
|
||||
[$user, $playlist] = $this->makePlaylist(3);
|
||||
$otherUser = User::factory()->create();
|
||||
|
||||
$this->actingAs($otherUser);
|
||||
|
||||
$second = PlaylistEpisode::where('playlist_id', $playlist->id)->where('position', 2)->first();
|
||||
|
||||
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
|
||||
->call('moveUp', $second->id);
|
||||
|
||||
$this->assertSame(2, PlaylistEpisode::find($second->id)->position);
|
||||
$this->assertSame(
|
||||
[1, 2, 3],
|
||||
PlaylistEpisode::where('playlist_id', $playlist->id)->orderBy('position')->pluck('position')->all()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_owner_can_move_episode_up_and_down(): void
|
||||
{
|
||||
[$user, $playlist] = $this->makePlaylist(3);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$second = PlaylistEpisode::where('playlist_id', $playlist->id)->where('position', 2)->first();
|
||||
|
||||
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
|
||||
->call('moveUp', $second->id)
|
||||
->assertOk();
|
||||
|
||||
$this->assertSame(1, PlaylistEpisode::find($second->id)->position);
|
||||
|
||||
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
|
||||
->call('moveDown', $second->id)
|
||||
->assertOk();
|
||||
|
||||
$this->assertSame(2, PlaylistEpisode::find($second->id)->position);
|
||||
$this->assertSame(
|
||||
[1, 2, 3],
|
||||
PlaylistEpisode::where('playlist_id', $playlist->id)->orderBy('position')->pluck('position')->all()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_owner_can_remove_episode_and_positions_reorder(): void
|
||||
{
|
||||
[$user, $playlist] = $this->makePlaylist(5);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$second = PlaylistEpisode::where('playlist_id', $playlist->id)->where('position', 2)->first();
|
||||
|
||||
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
|
||||
->call('remove', $second->id)
|
||||
->assertOk();
|
||||
|
||||
$this->assertDatabaseMissing('playlist_episodes', ['id' => $second->id]);
|
||||
$this->assertSame(
|
||||
[1, 2, 3, 4],
|
||||
PlaylistEpisode::where('playlist_id', $playlist->id)->orderBy('position')->pluck('position')->all()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_removing_last_item_on_last_page_clamps_page(): void
|
||||
{
|
||||
[$user, $playlist] = $this->makePlaylist(26);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$last = PlaylistEpisode::where('playlist_id', $playlist->id)->where('position', 26)->first();
|
||||
|
||||
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
|
||||
->call('gotoPage', 2)
|
||||
->assertSet('paginators.page', 2)
|
||||
->call('remove', $last->id)
|
||||
->assertSet('paginators.page', 1)
|
||||
->assertViewHas('episodes', fn ($episodes) => $episodes->total() === 25 && ! $episodes->hasPages());
|
||||
}
|
||||
|
||||
public function test_edit_name_and_toggle_visibility_still_work(): void
|
||||
{
|
||||
[$user, $playlist] = $this->makePlaylist(2);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(PlaylistOverview::class, ['playlist_id' => $playlist->id])
|
||||
->call('editName')
|
||||
->set('editingPlaylistName', 'Renamed Playlist')
|
||||
->call('updateName')
|
||||
->call('toggleVisibility')
|
||||
->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('playlists', [
|
||||
'id' => $playlist->id,
|
||||
'name' => 'Renamed Playlist',
|
||||
'is_private' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_public_playlist_page_renders(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$playlist = Playlist::factory()->create(['user_id' => $user->id, 'is_private' => false]);
|
||||
|
||||
$this->get('/playlist/'.$playlist->id)->assertOk();
|
||||
|
||||
$private = Playlist::factory()->create(['user_id' => $user->id, 'is_private' => true]);
|
||||
|
||||
$this->get('/playlist/'.$private->id)->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_user_playlist_page_renders(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$playlist = Playlist::factory()->create(['user_id' => $user->id, 'is_private' => true]);
|
||||
|
||||
$this->actingAs($user)->get('/user/playlist/'.$playlist->id)->assertOk();
|
||||
|
||||
$otherUser = User::factory()->create();
|
||||
|
||||
$this->actingAs($otherUser)->get('/user/playlist/'.$playlist->id)->assertNotFound();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user