feat(frontend): redesign playlist pages with cover image support

Create and edit playlist modals now upload/preview a cover image
(reusing the presigned-upload flow already used for tracks) and share
identical layout and coloring. Adds a cover proxy route and wires
PlaylistArt to render a playlist's real cover before falling back to
the track mosaic/gradient.

Also reworks the playlist detail page: full-width borderless "Añadir
de tu biblioteca" table with listens count and click-to-play, drops
the empty-playlist placeholder message, and restyles the playlists
grid to match the rest of the app.
This commit is contained in:
2026-07-15 16:10:04 +02:00
parent e98798894e
commit d1cba7b846
6 changed files with 390 additions and 110 deletions
@@ -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<string, string> = {
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 });