diff --git a/app/Livewire/PlaylistOverview.php b/app/Livewire/PlaylistOverview.php index 9247149..38a796a 100644 --- a/app/Livewire/PlaylistOverview.php +++ b/app/Livewire/PlaylistOverview.php @@ -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])); + } } diff --git a/app/Models/Gallery.php b/app/Models/Gallery.php index 57b0847..27c9bac 100644 --- a/app/Models/Gallery.php +++ b/app/Models/Gallery.php @@ -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 + */ + protected $guarded = []; + /** * Belongs To Episode. */ diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php index 28d17cf..ef3315e 100644 --- a/app/Models/Playlist.php +++ b/app/Models/Playlist.php @@ -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. * diff --git a/app/Models/PlaylistEpisode.php b/app/Models/PlaylistEpisode.php index ebfa928..eb83f61 100644 --- a/app/Models/PlaylistEpisode.php +++ b/app/Models/PlaylistEpisode.php @@ -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. * diff --git a/database/factories/GalleryFactory.php b/database/factories/GalleryFactory.php new file mode 100644 index 0000000..571ff2e --- /dev/null +++ b/database/factories/GalleryFactory.php @@ -0,0 +1,29 @@ + + */ +class GalleryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'hentai_id' => Hentai::factory(), + 'episode_id' => Episode::factory(), + 'image_url' => $this->faker->url(), + 'thumbnail_url' => $this->faker->url(), + ]; + } +} diff --git a/database/factories/PlaylistEpisodeFactory.php b/database/factories/PlaylistEpisodeFactory.php new file mode 100644 index 0000000..5a1d0bf --- /dev/null +++ b/database/factories/PlaylistEpisodeFactory.php @@ -0,0 +1,28 @@ + + */ +class PlaylistEpisodeFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'playlist_id' => Playlist::factory(), + 'episode_id' => Episode::factory(), + 'position' => $this->faker->numberBetween(1, 1000), + ]; + } +} diff --git a/database/factories/PlaylistFactory.php b/database/factories/PlaylistFactory.php new file mode 100644 index 0000000..6aa0964 --- /dev/null +++ b/database/factories/PlaylistFactory.php @@ -0,0 +1,27 @@ + + */ +class PlaylistFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'name' => $this->faker->word(), + 'is_private' => true, + ]; + } +} diff --git a/lang/de/playlist.php b/lang/de/playlist.php index af9d9b2..6179e8b 100644 --- a/lang/de/playlist.php +++ b/lang/de/playlist.php @@ -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', ]; diff --git a/lang/en/playlist.php b/lang/en/playlist.php index d48a9e3..84e3b59 100644 --- a/lang/en/playlist.php +++ b/lang/en/playlist.php @@ -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', ]; diff --git a/lang/fr/playlist.php b/lang/fr/playlist.php index 3ee8a8d..eba8e64 100644 --- a/lang/fr/playlist.php +++ b/lang/fr/playlist.php @@ -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', ]; diff --git a/resources/views/livewire/partials/playlist-episode-row.blade.php b/resources/views/livewire/partials/playlist-episode-row.blade.php new file mode 100644 index 0000000..d178bee --- /dev/null +++ b/resources/views/livewire/partials/playlist-episode-row.blade.php @@ -0,0 +1,73 @@ +@props([ + 'playlistEpisode', + 'isOwner' => false, +]) + +@php + $episode = $playlistEpisode->episode; +@endphp + +
+
+ {{ $playlistEpisode->position }} +
+ + + {{ $episode->title }} - {{ $episode->episode }} + + +
+ + {{ $episode->title }} - {{ $episode->episode }} + + + @if ($episode->title_jpn) +

{{ $episode->title_jpn }}

+ @endif + +
+ + {{ $episode->getResolution() }} + + + {{ $episode->viewCountFormatted() }} + + + {{ $episode->likeCount() }} + + + {{ $episode->commentCount() }} + + @auth + @if ($episode->userWatched(auth()->id())) + + {{ __('playlist.watched') }} + + @endif + @endauth +
+
+ + @if ($isOwner) +
+ + + +
+ @endif +
diff --git a/resources/views/livewire/playlist-overview.blade.php b/resources/views/livewire/playlist-overview.blade.php index 683d5e7..ee570f3 100644 --- a/resources/views/livewire/playlist-overview.blade.php +++ b/resources/views/livewire/playlist-overview.blade.php @@ -1,57 +1,58 @@ -
+
-
-
- -
-
+
+ + +
@if ($editingName) -
+
-
@error('editingPlaylistName') -

{{ $message }}

+

{{ $message }}

@enderror @else -

- {{ $playlist->name }} +

+ {{ $playlist->name }} @auth @if (Auth::id() === $playlist->user->id) - @endif @endauth

@endif -

Episodes: {{ count($playlistEpisodes) }}

-

+

{{ __('playlist.episodes') }}: {{ $playlist->episodes_count }}

+

Creator: {{ $playlist->user->name }} @auth @if (Auth::id() === $playlist->user->id) - @else - + {{ $playlist->is_private ? 'Private' : 'Public' }} @@ -59,88 +60,86 @@ @endauth

-
-
- @php $episode = $playlistEpisodes->first()?->episode; @endphp - @if(isset($episode)) - {{ __('playlist.play') }} - @else - {{ __('playlist.play') }} - @endif -
+ +
+ @if ($firstEpisode) + + {{ __('playlist.play') }} + + @else + + {{ __('playlist.play') }} + + @endif
- @forelse($playlistEpisodes as $playlistEpisode) - @php $episode = $playlistEpisode->episode; @endphp -
-
- {{ $playlistEpisode->position }} + +
+
+
+ + +
-
diff --git a/tests/Feature/Livewire/PlaylistOverviewTest.php b/tests/Feature/Livewire/PlaylistOverviewTest.php new file mode 100644 index 0000000..5a43422 --- /dev/null +++ b/tests/Feature/Livewire/PlaylistOverviewTest.php @@ -0,0 +1,261 @@ +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(); + } +}