From 16a20dd554aebe90bbfac978dc500db4f2b7b8d4 Mon Sep 17 00:00:00 2001 From: Phan An Date: Fri, 26 Jul 2024 16:33:14 +0200 Subject: [PATCH] feat: rework playlist cover upload/remove (#1806) --- .github/workflows/unit-frontend.yml | 2 +- .../API/PlaylistCoverController.php | 31 ++++++ .../API/UploadPlaylistCoverController.php | 22 ---- ...est.php => PlaylistCoverUpdateRequest.php} | 2 +- app/Services/MediaMetadataService.php | 8 ++ .../playlist/PlaylistContextMenu.vue | 8 +- .../components/screens/PlaylistScreen.spec.ts | 2 + .../js/components/ui/PlaylistThumbnail.vue | 103 ++++++++---------- resources/assets/js/stores/playlistStore.ts | 5 + routes/api.base.php | 5 +- tests/Feature/KoelPlus/PlaylistCoverTest.php | 11 ++ tests/Feature/PlaylistCoverTest.php | 49 +++++---- 12 files changed, 144 insertions(+), 104 deletions(-) create mode 100644 app/Http/Controllers/API/PlaylistCoverController.php delete mode 100644 app/Http/Controllers/API/UploadPlaylistCoverController.php rename app/Http/Requests/API/{UploadPlaylistCoverRequest.php => PlaylistCoverUpdateRequest.php} (72%) diff --git a/.github/workflows/unit-frontend.yml b/.github/workflows/unit-frontend.yml index c7e1626b..85ce4597 100644 --- a/.github/workflows/unit-frontend.yml +++ b/.github/workflows/unit-frontend.yml @@ -30,7 +30,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [ 18, 20, 21, 22 ] + node-version: [ 18 ] steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 diff --git a/app/Http/Controllers/API/PlaylistCoverController.php b/app/Http/Controllers/API/PlaylistCoverController.php new file mode 100644 index 00000000..19b0a39b --- /dev/null +++ b/app/Http/Controllers/API/PlaylistCoverController.php @@ -0,0 +1,31 @@ +authorize('own', $playlist); + $this->mediaMetadataService->writePlaylistCover($playlist, $request->getFileContent()); + + return response()->json(['cover_url' => $playlist->cover]); + } + + public function destroy(Playlist $playlist) + { + $this->authorize('own', $playlist); + $this->mediaMetadataService->deletePlaylistCover($playlist); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/API/UploadPlaylistCoverController.php b/app/Http/Controllers/API/UploadPlaylistCoverController.php deleted file mode 100644 index 9f9c2683..00000000 --- a/app/Http/Controllers/API/UploadPlaylistCoverController.php +++ /dev/null @@ -1,22 +0,0 @@ -authorize('own', $playlist); - $mediaMetadataService->writePlaylistCover($playlist, $request->getFileContent()); - - return response()->json(['cover_url' => $playlist->cover]); - } -} diff --git a/app/Http/Requests/API/UploadPlaylistCoverRequest.php b/app/Http/Requests/API/PlaylistCoverUpdateRequest.php similarity index 72% rename from app/Http/Requests/API/UploadPlaylistCoverRequest.php rename to app/Http/Requests/API/PlaylistCoverUpdateRequest.php index 20fff0f9..fb5e8533 100644 --- a/app/Http/Requests/API/UploadPlaylistCoverRequest.php +++ b/app/Http/Requests/API/PlaylistCoverUpdateRequest.php @@ -3,7 +3,7 @@ namespace App\Http\Requests\API; /** @property-read string $cover */ -class UploadPlaylistCoverRequest extends MediaImageUpdateRequest +class PlaylistCoverUpdateRequest extends MediaImageUpdateRequest { protected function getImageFieldName(): string { diff --git a/app/Services/MediaMetadataService.php b/app/Services/MediaMetadataService.php index b0dd41f6..0f082557 100644 --- a/app/Services/MediaMetadataService.php +++ b/app/Services/MediaMetadataService.php @@ -135,4 +135,12 @@ class MediaMetadataService File::delete($album->cover_path); File::delete($album->thumbnail_path); } + + public function deletePlaylistCover(Playlist $playlist): void + { + if ($playlist->cover_path) { + File::delete($playlist->cover_path); + $playlist->update(['cover' => null]); + } + } } diff --git a/resources/assets/js/components/playlist/PlaylistContextMenu.vue b/resources/assets/js/components/playlist/PlaylistContextMenu.vue index a18b2543..74f918f1 100644 --- a/resources/assets/js/components/playlist/PlaylistContextMenu.vue +++ b/resources/assets/js/components/playlist/PlaylistContextMenu.vue @@ -6,10 +6,12 @@ -
  • Edit…
  • -
  • Delete
  • + diff --git a/resources/assets/js/components/screens/PlaylistScreen.spec.ts b/resources/assets/js/components/screens/PlaylistScreen.spec.ts index 17f01765..7240f772 100644 --- a/resources/assets/js/components/screens/PlaylistScreen.spec.ts +++ b/resources/assets/js/components/screens/PlaylistScreen.spec.ts @@ -59,6 +59,8 @@ new class extends UnitTestCase { private async renderComponent (songs: Playable[]) { playlist = playlist || factory('playlist') + this.be(factory('user', { id: playlist.user_id })) + playlistStore.init([playlist]) playlist.playables = songs diff --git a/resources/assets/js/components/ui/PlaylistThumbnail.vue b/resources/assets/js/components/ui/PlaylistThumbnail.vue index 616c9183..a2c4a04e 100644 --- a/resources/assets/js/components/ui/PlaylistThumbnail.vue +++ b/resources/assets/js/components/ui/PlaylistThumbnail.vue @@ -1,15 +1,27 @@ @@ -18,74 +30,55 @@ import { computed, ref, toRefs } from 'vue' import { defaultCover } from '@/utils' import { playlistStore } from '@/stores' -import { useAuthorization, useErrorHandler, useFileReader, useKoelPlus } from '@/composables' -import { acceptedImageTypes } from '@/config' +import { useErrorHandler, useFileReader, useKoelPlus, usePolicies } from '@/composables' +import { faTrash, faUpload } from '@fortawesome/free-solid-svg-icons' const props = defineProps<{ playlist: Playlist }>() const { playlist } = toRefs(props) -const droppable = ref(false) - -const { isAdmin, currentUser } = useAuthorization() +const { currentUserCan } = usePolicies() const { isPlus } = useKoelPlus() +const canEditPlaylist = computed(() => currentUserCan.editPlaylist(playlist.value!)) const backgroundImage = computed(() => `url(${playlist.value.cover || defaultCover})`) -const allowsUpload = computed(() => (isAdmin.value || isPlus.value) && playlist.value.user_id === currentUser.value.id) -const onDragEnter = () => (droppable.value = allowsUpload.value) -const onDragLeave = () => (droppable.value = false) +const uploadCover = () => { + const input = document.createElement('input') + input.setAttribute('type', 'file') + input.setAttribute('accept', 'image/*') -const validImageDropEvent = (event: DragEvent) => { - if (!event.dataTransfer || !event.dataTransfer.items) { - return false - } + input.addEventListener('change', async () => { + const file = input.files?.[0] - if (event.dataTransfer.items.length !== 1) { - return false - } + if (!file) { + return + } - if (event.dataTransfer.items[0].kind !== 'file') { - return false - } + const backupImage = playlist.value.cover - return acceptedImageTypes.includes(event.dataTransfer.items[0].getAsFile()!.type) + try { + useFileReader().readAsDataUrl(file, async url => { + playlist.value!.cover = url + await playlistStore.uploadCover(playlist.value, url) + toastSuccess('Playlist cover updated.') + }) + } catch (error: unknown) { + // restore the backup image + playlist.value.cover = backupImage + useErrorHandler().handleHttpError(error) + } + }) + + input.dispatchEvent(new MouseEvent('click')) } -const onDrop = async (event: DragEvent) => { - droppable.value = false - - if (!allowsUpload.value) { - return - } - - if (!validImageDropEvent(event)) { - return - } - - const backupImage = playlist.value.cover - - try { - useFileReader().readAsDataUrl(event.dataTransfer!.files[0], async url => { - // Replace the image right away to create an "instant" effect - playlist.value.cover = url - await playlistStore.uploadCover(playlist.value, url) - }) - } catch (error: unknown) { - // restore the backup image - playlist.value.cover = backupImage - useErrorHandler().handleHttpError(error) - } -} +const removeCover = async () => await playlistStore.removeCover(playlist.value)