diff --git a/frontend/src/lib/components/ui/PlaylistArt.svelte b/frontend/src/lib/components/ui/PlaylistArt.svelte index 14535a4..1e5e251 100644 --- a/frontend/src/lib/components/ui/PlaylistArt.svelte +++ b/frontend/src/lib/components/ui/PlaylistArt.svelte @@ -3,12 +3,14 @@ import { gradientForHue, HUES } from '$lib/theme/color'; interface Props { + playlistId?: string; trackIds: (string | number)[]; hue: number; + size?: 'small' | 'medium' | 'large'; class?: string; } - let { trackIds, hue, class: klass = '' }: Props = $props(); + let { playlistId, trackIds, hue, size = 'medium', class: klass = '' }: Props = $props(); function hueFor(id: string | number) { const text = String(id); @@ -17,11 +19,23 @@ return HUES[hash % HUES.length]; } + let coverFailed = $state(false); + const mosaic = $derived(trackIds.length >= 4); const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null); -{#if mosaic} +{#if playlistId && !coverFailed} +
+ (coverFailed = true)} + class="h-full w-full object-cover" + /> +
+{:else if mosaic}
{#each trackIds.slice(0, 4) as id (id)} diff --git a/frontend/src/routes/(app)/playlists/+page.server.ts b/frontend/src/routes/(app)/playlists/+page.server.ts index f573652..465201e 100644 --- a/frontend/src/routes/(app)/playlists/+page.server.ts +++ b/frontend/src/routes/(app)/playlists/+page.server.ts @@ -1,9 +1,27 @@ import type { PageServerLoad, Actions } from './$types'; import { error, fail, redirect } from '@sveltejs/kit'; import { createApiClient } from '$lib/server/api'; +import { putPresigned } from '$lib/server/upload'; const MAX_NAME = 100; +const IMAGE_TYPES: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp' +}; + +function extOf(name: string): string { + const dot = name.lastIndexOf('.'); + return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''; +} + +function contentTypeOf(file: File): string { + if (file.type) return file.type; + return IMAGE_TYPES[extOf(file.name)] ?? 'application/octet-stream'; +} + export const load: PageServerLoad = async ({ locals, fetch }) => { if (!locals.user) error(401, 'Inicia sesión.'); @@ -33,14 +51,46 @@ export const actions: Actions = { const form = await request.formData(); const name = String(form.get('name') ?? '').trim(); const description = String(form.get('description') ?? '').trim(); + const cover = form.get('cover'); if (name === '' || name.length > MAX_NAME) { return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).', name, description }); } const api = createApiClient({ fetch, accessToken: locals.accessToken }); + + let pictureIntentId: string | null = null; + if (cover instanceof File && cover.size > 0) { + const contentType = contentTypeOf(cover); + const { data: upload, error: uploadError } = await api.POST('/playlists/upload-picture', { + body: { + fileType: extOf(cover.name) || 'jpg', + contentType, + expectedSizeBytes: cover.size + } + }); + + if (uploadError || !upload) { + return fail(502, { message: 'No se pudo reservar la subida de la portada.', name, description }); + } + + try { + const coverBuffer = await cover.arrayBuffer(); + await putPresigned(upload.uploadUrl, Buffer.from(coverBuffer), upload.contentType); + } catch (err) { + return fail(502, { + message: 'Falló la subida de la portada al almacenamiento.', + name, + description, + detail: err instanceof Error ? err.message : undefined + }); + } + + pictureIntentId = upload.intentId; + } + const { data, error: err } = await api.POST('/playlists', { - body: { name, description, pictureIntentId: null } + body: { name, description, pictureIntentId } }); if (err || !data) return fail(502, { message: 'No se pudo crear la playlist.', name, description }); diff --git a/frontend/src/routes/(app)/playlists/+page.svelte b/frontend/src/routes/(app)/playlists/+page.svelte index 15889f8..b5f3102 100644 --- a/frontend/src/routes/(app)/playlists/+page.svelte +++ b/frontend/src/routes/(app)/playlists/+page.svelte @@ -3,9 +3,27 @@ import Plus from '@lucide/svelte/icons/plus'; import X from '@lucide/svelte/icons/x'; import ListMusic from '@lucide/svelte/icons/list-music'; + import ImageIcon from '@lucide/svelte/icons/image'; import { HUES } from '$lib/theme/color'; import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; - + + const playlistNames = [ + "Mi Playlist", + "Tus favoritos", + "Lo mejor de hoy", + "Para relajarse", + "Buena vibra", + "Modo estudio", + "Road Trip", + "Domingos", + "Late Night", + "Descubriendo música", + "Todo un poco", + "En bucle", + "Mi colección", + "Canciones para repetir", + "Hits del momento" + ]; let { data, form } = $props(); const items = $derived(data.playlists.items); @@ -15,6 +33,7 @@ let submitting = $state(false); let name = $state(''); let description = $state(''); + let coverPreview = $state(''); function hueFor(id: string) { let hash = 0; @@ -25,6 +44,11 @@ function openCreate() { creating = true; } + + function onCoverInput(event: Event) { + const file = (event.currentTarget as HTMLInputElement).files?.[0]; + if (file) coverPreview = URL.createObjectURL(file); + } @@ -38,48 +62,54 @@

Mis listas

Crea y organiza tus colecciones.

- + {#if items.length > 0} + + {/if} {#if items.length > 0}
- {#each items as playlist (playlist.id)} + {#each items as playlist, i (playlist.id)} -
{playlist.name}
+
+ {playlist.name} +
{#if playlist.description} -
{playlist.description}
+
{playlist.description}
{/if}
{/each}
{:else}
- + -

Todavía no tienes listas

-

Crea la primera y añádele canciones de tu biblioteca.

+

Todavía no tienes listas

+

Crea la primera y añádele canciones de tu biblioteca.

diff --git a/frontend/src/routes/(app)/playlists/[id]/+page.server.ts b/frontend/src/routes/(app)/playlists/[id]/+page.server.ts index ba854f3..dfd734a 100644 --- a/frontend/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/frontend/src/routes/(app)/playlists/[id]/+page.server.ts @@ -1,9 +1,27 @@ import type { PageServerLoad, Actions } from './$types'; import { error, fail, redirect } from '@sveltejs/kit'; import { createApiClient } from '$lib/server/api'; +import { putPresigned } from '$lib/server/upload'; const MAX_NAME = 100; +const IMAGE_TYPES: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp' +}; + +function extOf(name: string): string { + const dot = name.lastIndexOf('.'); + return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''; +} + +function contentTypeOf(file: File): string { + if (file.type) return file.type; + return IMAGE_TYPES[extOf(file.name)] ?? 'application/octet-stream'; +} + export const load: PageServerLoad = async ({ params, locals, fetch }) => { if (!locals.user) error(401, 'Inicia sesión.'); @@ -60,14 +78,44 @@ export const actions: Actions = { const form = await request.formData(); const name = String(form.get('name') ?? '').trim(); const description = String(form.get('description') ?? '').trim(); + const cover = form.get('cover'); if (name === '' || name.length > MAX_NAME) { return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' }); } const api = createApiClient({ fetch, accessToken: locals.accessToken }); + + let newPictureIntentId: string | null = null; + if (cover instanceof File && cover.size > 0) { + const contentType = contentTypeOf(cover); + const { data: upload, error: uploadError } = await api.POST('/playlists/upload-picture', { + body: { + fileType: extOf(cover.name) || 'jpg', + contentType, + expectedSizeBytes: cover.size + } + }); + + if (uploadError || !upload) { + return fail(502, { message: 'No se pudo reservar la subida de la portada.' }); + } + + try { + const coverBuffer = await cover.arrayBuffer(); + await putPresigned(upload.uploadUrl, Buffer.from(coverBuffer), upload.contentType); + } catch (err) { + return fail(502, { + message: 'Falló la subida de la portada al almacenamiento.', + detail: err instanceof Error ? err.message : undefined + }); + } + + newPictureIntentId = upload.intentId; + } + const { error: err } = await api.PUT('/playlists/{playlistId}', { params: { path: { playlistId: params.id } }, - body: { newName: name, newDescription: description, newPictureIntentId: null } + body: { newName: name, newDescription: description, newPictureIntentId } }); if (err) return fail(502, { message: 'No se pudo actualizar la playlist.' }); return { renamed: true }; diff --git a/frontend/src/routes/(app)/playlists/[id]/+page.svelte b/frontend/src/routes/(app)/playlists/[id]/+page.svelte index baec492..0adb6b3 100644 --- a/frontend/src/routes/(app)/playlists/[id]/+page.svelte +++ b/frontend/src/routes/(app)/playlists/[id]/+page.svelte @@ -7,8 +7,9 @@ import Trash2 from '@lucide/svelte/icons/trash-2'; import Plus from '@lucide/svelte/icons/plus'; import X from '@lucide/svelte/icons/x'; - import Music from '@lucide/svelte/icons/music'; import Clock from '@lucide/svelte/icons/clock'; + import ImageIcon from '@lucide/svelte/icons/image'; + import Headphones from '@lucide/svelte/icons/headphones'; import { player } from '$lib/player/player.svelte'; import { HUES, fmtTime } from '$lib/theme/color'; import Cover from '$lib/components/ui/Cover.svelte'; @@ -23,6 +24,20 @@ let editing = $state(false); let confirmingDelete = $state(false); + let editCoverPreview = $state(''); + let editCoverFailed = $state(false); + let savingEdit = $state(false); + + function openEdit() { + editCoverPreview = ''; + editCoverFailed = false; + editing = true; + } + + function onEditCoverInput(event: Event) { + const file = (event.currentTarget as HTMLInputElement).files?.[0]; + if (file) editCoverPreview = URL.createObjectURL(file); + } const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' }); @@ -45,6 +60,12 @@ if (player.current.id === track.id) player.toggle(); else player.playQueue(tracks, index); } + + function playFromLibrary(index: number) { + const track = library[index]; + if (player.current.id === track.id) player.toggle(); + else player.playQueue(library, index); + } @@ -67,8 +88,10 @@
t.id)} hue={hueFor(playlist.id)} + size="large" class="h-44 w-44 flex-shrink-0 rounded-2xl shadow-[0_20px_45px_-15px_rgba(0,0,0,0.7)]" />
@@ -101,7 +124,7 @@ +
+ + {Number(track.listensCount)} +
+ async ({ update }) => update()} + class="flex justify-end" + > @@ -226,9 +265,9 @@ if (e.key === 'Escape') editing = false; }} > -
+
-

Editar playlist

+

Editar playlist

diff --git a/frontend/src/routes/api/playlists/[id]/cover/+server.ts b/frontend/src/routes/api/playlists/[id]/cover/+server.ts new file mode 100644 index 0000000..bc69a85 --- /dev/null +++ b/frontend/src/routes/api/playlists/[id]/cover/+server.ts @@ -0,0 +1,25 @@ +import type { RequestHandler } from './$types'; +import { apiConfig } from '$lib/server/config'; + +const ALLOWED = new Set(['small', 'medium', 'large']); + +export const GET: RequestHandler = async ({ params, url, fetch }) => { + const size = url.searchParams.get('size') ?? 'medium'; + const safeSize = ALLOWED.has(size) ? size : 'medium'; + + const upstream = await fetch( + `${apiConfig.baseUrl}/playlists/${params.id}/cover?size=${safeSize}` + ); + + if (!upstream.ok) { + return new Response(null, { status: upstream.status }); + } + + return new Response(upstream.body, { + status: 200, + headers: { + 'Content-Type': upstream.headers.get('content-type') ?? 'image/webp', + 'Cache-Control': 'public, max-age=86400' + } + }); +};