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
@@ -3,12 +3,14 @@
import { gradientForHue, HUES } from '$lib/theme/color'; import { gradientForHue, HUES } from '$lib/theme/color';
interface Props { interface Props {
playlistId?: string;
trackIds: (string | number)[]; trackIds: (string | number)[];
hue: number; hue: number;
size?: 'small' | 'medium' | 'large';
class?: string; class?: string;
} }
let { trackIds, hue, class: klass = '' }: Props = $props(); let { playlistId, trackIds, hue, size = 'medium', class: klass = '' }: Props = $props();
function hueFor(id: string | number) { function hueFor(id: string | number) {
const text = String(id); const text = String(id);
@@ -17,11 +19,23 @@
return HUES[hash % HUES.length]; return HUES[hash % HUES.length];
} }
let coverFailed = $state(false);
const mosaic = $derived(trackIds.length >= 4); const mosaic = $derived(trackIds.length >= 4);
const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null); const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null);
</script> </script>
{#if mosaic} {#if playlistId && !coverFailed}
<div class="relative overflow-hidden {klass}" style="background:{gradientForHue(hue)}">
<img
src="/api/playlists/{playlistId}/cover?size={size}"
alt=""
loading="lazy"
onerror={() => (coverFailed = true)}
class="h-full w-full object-cover"
/>
</div>
{:else if mosaic}
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}"> <div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
{#each trackIds.slice(0, 4) as id (id)} {#each trackIds.slice(0, 4) as id (id)}
<Cover trackId={id} hue={hueFor(id)} size="small" class="h-full w-full" /> <Cover trackId={id} hue={hueFor(id)} size="small" class="h-full w-full" />
@@ -1,9 +1,27 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api'; import { createApiClient } from '$lib/server/api';
import { putPresigned } from '$lib/server/upload';
const MAX_NAME = 100; 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 }) => { export const load: PageServerLoad = async ({ locals, fetch }) => {
if (!locals.user) error(401, 'Inicia sesión.'); if (!locals.user) error(401, 'Inicia sesión.');
@@ -33,14 +51,46 @@ export const actions: Actions = {
const form = await request.formData(); const form = await request.formData();
const name = String(form.get('name') ?? '').trim(); const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim(); const description = String(form.get('description') ?? '').trim();
const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) { if (name === '' || name.length > MAX_NAME) {
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).', name, description }); return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).', name, description });
} }
const api = createApiClient({ fetch, accessToken: locals.accessToken }); 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', { 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 }); if (err || !data) return fail(502, { message: 'No se pudo crear la playlist.', name, description });
+111 -52
View File
@@ -3,9 +3,27 @@
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import ListMusic from '@lucide/svelte/icons/list-music'; import ListMusic from '@lucide/svelte/icons/list-music';
import ImageIcon from '@lucide/svelte/icons/image';
import { HUES } from '$lib/theme/color'; import { HUES } from '$lib/theme/color';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; 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(); let { data, form } = $props();
const items = $derived(data.playlists.items); const items = $derived(data.playlists.items);
@@ -15,6 +33,7 @@
let submitting = $state(false); let submitting = $state(false);
let name = $state(''); let name = $state('');
let description = $state(''); let description = $state('');
let coverPreview = $state('');
function hueFor(id: string) { function hueFor(id: string) {
let hash = 0; let hash = 0;
@@ -25,6 +44,11 @@
function openCreate() { function openCreate() {
creating = true; creating = true;
} }
function onCoverInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) coverPreview = URL.createObjectURL(file);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -38,48 +62,54 @@
<h1 class="font-display text-3xl font-bold tracking-tight sm:text-4xl">Mis listas</h1> <h1 class="font-display text-3xl font-bold tracking-tight sm:text-4xl">Mis listas</h1>
<p class="mt-2 text-neutral-400">Crea y organiza tus colecciones.</p> <p class="mt-2 text-neutral-400">Crea y organiza tus colecciones.</p>
</div> </div>
<button {#if items.length > 0}
type="button" <button
onclick={openCreate} type="button"
class="inline-flex items-center gap-2 rounded-lg bg-[var(--mf-accent)] px-6 py-3 text-sm font-semibold text-neutral-950 transition hover:brightness-110 hover:shadow-[0_8px_24px_-8px] active:scale-[0.97]" onclick={openCreate}
> class="inline-flex items-center gap-2 rounded-lg bg-[var(--mf-accent)] px-5 py-2.5 text-sm font-semibold text-neutral-950 transition hover:brightness-110 hover:shadow-[0_8px_24px_-8px] active:scale-[0.97]"
<Plus class="h-4 w-4" strokeWidth={2.5} /> >
Crear lista <Plus class="h-4 w-4" strokeWidth={2.5} />
</button> Crear lista
</button>
{/if}
</div> </div>
{#if items.length > 0} {#if items.length > 0}
<div class="mt-9 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4"> <div class="mt-9 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
{#each items as playlist (playlist.id)} {#each items as playlist, i (playlist.id)}
<a <a
href="/playlists/{playlist.id}" href="/playlists/{playlist.id}"
class="group rounded-2xl border border-white/5 bg-neutral-900/40 p-4 transition hover:border-emerald-500/30 hover:bg-neutral-900/60" class="group animate-enter text-left"
style="animation-delay:{i * 45}ms"
> >
<PlaylistArt <PlaylistArt
playlistId={playlist.id}
trackIds={trackIds[playlist.id] ?? []} trackIds={trackIds[playlist.id] ?? []}
hue={hueFor(playlist.id)} hue={hueFor(playlist.id)}
class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]" class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)] transition group-hover:shadow-[0_16px_36px_-10px_rgba(0,0,0,0.75)]"
/> />
<div class="mt-3 truncate text-sm font-semibold text-neutral-100">{playlist.name}</div> <div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]">
{playlist.name}
</div>
{#if playlist.description} {#if playlist.description}
<div class="mt-0.5 truncate text-xs text-neutral-400">{playlist.description}</div> <div class="mt-0.5 truncate text-xs text-[var(--mf-text-3)]">{playlist.description}</div>
{/if} {/if}
</a> </a>
{/each} {/each}
</div> </div>
{:else} {:else}
<div <div
class="mt-9 rounded-2xl border border-white/10 bg-neutral-900/40 p-14 text-center" class="mt-9 p-14 text-center"
> >
<span class="mx-auto grid h-14 w-14 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950"> <span class="mx-auto grid h-16 w-16 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950">
<ListMusic class="h-7 w-7" /> <ListMusic class="h-7 w-7" />
</span> </span>
<h3 class="mt-5 font-display text-xl font-bold tracking-tight">Todavía no tienes listas</h3> <h3 class="mt-5 font-display text-2xl font-bold tracking-tight">Todavía no tienes listas</h3>
<p class="mt-2 text-sm text-neutral-400">Crea la primera y añádele canciones de tu biblioteca.</p> <p class="mt-2 text-neutral-400">Crea la primera y añádele canciones de tu biblioteca.</p>
<button <button
type="button" type="button"
onclick={openCreate} onclick={openCreate}
class="mt-6 inline-flex items-center gap-2 rounded-lg bg-[var(--mf-accent)] px-6 py-3 text-sm font-semibold text-neutral-950 transition hover:brightness-110 hover:shadow-[0_8px_24px_-8px] active:scale-[0.97]" class="mt-6 inline-flex items-center gap-2 rounded-lg bg-[var(--mf-accent)] px-5 py-2.5 text-sm font-semibold text-neutral-800 transition active:scale-[0.95]"
> >
<Plus class="h-4 w-4" strokeWidth={2.5} /> <Plus class="h-4 w-4" strokeWidth={2.5} />
Crear lista Crear lista
@@ -90,8 +120,8 @@
{#if creating} {#if creating}
<div <div
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-black/70 p-4 backdrop-blur-sm" class="animate-fade fixed inset-0 z-100 grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
role="button" role="dialog"
tabindex="0" tabindex="0"
onclick={(e) => { onclick={(e) => {
if (e.target === e.currentTarget) creating = false; if (e.target === e.currentTarget) creating = false;
@@ -100,9 +130,9 @@
if (e.key === 'Escape') creating = false; if (e.key === 'Escape') creating = false;
}} }}
> >
<div class="animate-pop w-full max-w-md rounded-2xl border border-white/10 bg-neutral-900 p-6 shadow-2xl"> <div class="animate-pop w-full max-w-2xl rounded-2xl bg-[var(--mf-bg)] p-6 shadow-2xl">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="font-display text-lg font-bold tracking-tight">Nueva lista</h2> <h2 class="text-2xl font-bold tracking-tight">Nueva lista</h2>
<button <button
type="button" type="button"
onclick={() => (creating = false)} onclick={() => (creating = false)}
@@ -116,6 +146,7 @@
<form <form
method="POST" method="POST"
action="?/create" action="?/create"
enctype="multipart/form-data"
use:enhance={() => { use:enhance={() => {
submitting = true; submitting = true;
return async ({ update }) => { return async ({ update }) => {
@@ -123,34 +154,62 @@
submitting = false; submitting = false;
}; };
}} }}
class="mt-5 space-y-4" class="mt-4 w-full max-w-4xl space-y-4"
> >
<div> <div class="flex items-stretch gap-8">
<label for="name" class="mb-2 block text-sm font-medium text-neutral-300">Nombre</label> <div class="flex flex-col">
<input <span class="mb-3 block text-sm font-medium text-neutral-300">
id="name" Portada <span class="text-neutral-500">(Opcional)</span>
name="name" </span>
type="text" <label
maxlength={100} class="relative grid h-52 w-52 cursor-pointer place-items-center overflow-hidden rounded-lg border border-transparent bg-[var(--mf-surface)] transition-colors hover:border-[var(--mf-accent)]/50 "
required >
bind:value={name} <input
placeholder="Mi nueva lista" type="file"
class="w-full rounded-xl border border-white/10 bg-neutral-950 px-4 py-3 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-emerald-500/60 focus:outline-none" name="cover"
/> accept="image/*"
</div> onchange={onCoverInput}
<div> class="absolute inset-0 cursor-pointer opacity-0"
<label for="description" class="mb-2 block text-sm font-medium text-neutral-300"> aria-label="Seleccionar portada"
Descripción <span class="text-neutral-500">(opcional)</span> />
</label> {#if coverPreview !== ''}
<textarea <img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
id="description" {:else}
name="description" <ImageIcon class="h-12 w-12 text-neutral-500" />
rows="3" {/if}
maxlength={300} </label>
bind:value={description} </div>
placeholder="¿De qué va esta lista?"
class="w-full resize-none rounded-xl border border-white/10 bg-neutral-950 px-4 py-3 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-emerald-500/60 focus:outline-none" <div class="flex flex-1 flex-col gap-4">
></textarea> <div>
<label for="name" class="mb-2 block text-sm font-medium text-neutral-300">Nombre</label>
<input
id="name"
name="name"
type="text"
maxlength={100}
required
autocomplete="off"
bind:value={name}
placeholder={playlistNames[Math.floor(Math.random() * playlistNames.length)]}
class="w-full rounded-lg border border-transparent bg-[var(--mf-surface)] px-4 py-3 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-[var(--mf-accent)]/50 focus:outline-none"
/>
</div>
<div class="flex min-h-0 flex-1 flex-col">
<label for="description" class="mb-2 block text-sm font-medium text-neutral-300">
Descripción <span class="text-neutral-500">(Opcional)</span>
</label>
<textarea
id="description"
name="description"
maxlength={300}
bind:value={description}
placeholder="¿De qué va esta lista?"
class="w-full min-h-0 flex-1 resize-none rounded-lg border border-transparent bg-[var(--mf-surface)] px-4 py-3 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-[var(--mf-accent)]/50 focus:outline-none"
></textarea>
</div>
</div>
</div> </div>
{#if form?.message} {#if form?.message}
@@ -159,7 +218,7 @@
</p> </p>
{/if} {/if}
<div class="flex items-center justify-end gap-3 pt-1"> <div class="flex items-center justify-end gap-3">
<button <button
type="button" type="button"
onclick={() => (creating = false)} onclick={() => (creating = false)}
@@ -169,8 +228,8 @@
</button> </button>
<button <button
type="submit" type="submit"
disabled={submitting} disabled={submitting || name.trim() === ''}
class="rounded-lg bg-[var(--mf-accent)] px-6 py-2.5 text-sm font-semibold text-neutral-950 transition hover:brightness-110 disabled:opacity-40" class="rounded-lg bg-[var(--mf-accent)] px-6 py-2.5 text-sm font-semibold text-neutral-800 transition disabled:opacity-40"
> >
{submitting ? 'Creando…' : 'Crear'} {submitting ? 'Creando…' : 'Crear'}
</button> </button>
@@ -1,9 +1,27 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api'; import { createApiClient } from '$lib/server/api';
import { putPresigned } from '$lib/server/upload';
const MAX_NAME = 100; 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 ({ params, locals, fetch }) => { export const load: PageServerLoad = async ({ params, locals, fetch }) => {
if (!locals.user) error(401, 'Inicia sesión.'); if (!locals.user) error(401, 'Inicia sesión.');
@@ -60,14 +78,44 @@ export const actions: Actions = {
const form = await request.formData(); const form = await request.formData();
const name = String(form.get('name') ?? '').trim(); const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim(); const description = String(form.get('description') ?? '').trim();
const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) { if (name === '' || name.length > MAX_NAME) {
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' }); return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
} }
const api = createApiClient({ fetch, accessToken: locals.accessToken }); 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}', { const { error: err } = await api.PUT('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } }, 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.' }); if (err) return fail(502, { message: 'No se pudo actualizar la playlist.' });
return { renamed: true }; return { renamed: true };
@@ -7,8 +7,9 @@
import Trash2 from '@lucide/svelte/icons/trash-2'; import Trash2 from '@lucide/svelte/icons/trash-2';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import Music from '@lucide/svelte/icons/music';
import Clock from '@lucide/svelte/icons/clock'; 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 { player } from '$lib/player/player.svelte';
import { HUES, fmtTime } from '$lib/theme/color'; import { HUES, fmtTime } from '$lib/theme/color';
import Cover from '$lib/components/ui/Cover.svelte'; import Cover from '$lib/components/ui/Cover.svelte';
@@ -23,6 +24,20 @@
let editing = $state(false); let editing = $state(false);
let confirmingDelete = $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' }); const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
@@ -45,6 +60,12 @@
if (player.current.id === track.id) player.toggle(); if (player.current.id === track.id) player.toggle();
else player.playQueue(tracks, index); 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);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -67,8 +88,10 @@
<div class="mt-6 flex flex-col gap-6 sm:flex-row sm:items-end"> <div class="mt-6 flex flex-col gap-6 sm:flex-row sm:items-end">
<PlaylistArt <PlaylistArt
playlistId={playlist.id}
trackIds={tracks.map((t) => t.id)} trackIds={tracks.map((t) => t.id)}
hue={hueFor(playlist.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)]" class="h-44 w-44 flex-shrink-0 rounded-2xl shadow-[0_20px_45px_-15px_rgba(0,0,0,0.7)]"
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
@@ -101,7 +124,7 @@
</button> </button>
<button <button
type="button" type="button"
onclick={() => (editing = true)} onclick={openEdit}
class="inline-flex h-12 items-center gap-2 rounded-lg border border-white/15 px-5 text-sm font-semibold text-white transition hover:bg-white/5" class="inline-flex h-12 items-center gap-2 rounded-lg border border-white/15 px-5 text-sm font-semibold text-white transition hover:bg-white/5"
> >
<Pencil class="h-4 w-4" /> <Pencil class="h-4 w-4" />
@@ -120,7 +143,7 @@
{#if tracks.length > 0} {#if tracks.length > 0}
<div class="mt-8"> <div class="mt-8">
<div <div
class="grid grid-cols-[32px_1fr_140px_64px_36px] items-center gap-4 border-b border-white/10 px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500" class="grid grid-cols-[32px_1fr_140px_64px_36px] items-center gap-4 border-b border-white/10 px-3 pb-2 text-xs uppercase tracking-wide text-neutral-500"
> >
<span class="text-center">#</span> <span class="text-center">#</span>
<span>Título</span> <span>Título</span>
@@ -176,34 +199,50 @@
{/each} {/each}
</div> </div>
</div> </div>
{:else}
<div class="mt-8 max-w-[900px] rounded-2xl border border-white/10 bg-neutral-900/40 p-10 text-center">
<Music class="mx-auto h-9 w-9 text-neutral-600" />
<p class="mt-4 text-neutral-300">Esta playlist está vacía. Añade canciones de tu biblioteca.</p>
</div>
{/if} {/if}
{#if library.length > 0} {#if library.length > 0}
<div class="mt-12 max-w-[900px]"> <div class="mt-12">
<h2 class="font-display text-lg font-bold tracking-tight">Añadir de tu biblioteca</h2> <h2 class="font-display text-lg font-bold tracking-tight">Añadir de tu biblioteca</h2>
<div class="mt-4 flex flex-col"> <div class="mt-4 flex flex-col">
{#each library as track (track.id)} {#each library as track, i (track.id)}
<div class="flex items-center gap-3.5 rounded-[10px] px-3 py-[10px] transition hover:bg-neutral-900/60"> <div
<Cover class="group grid grid-cols-[1fr_140px_110px] items-center gap-4 rounded-[10px] px-3 py-[10px] transition hover:bg-neutral-900/60"
trackId={track.id} >
hue={hueFor(track.id)} <button
size="small" type="button"
alt={track.title} onclick={() => playFromLibrary(i)}
class="h-[40px] w-[40px] flex-shrink-0 rounded-md" class="flex min-w-0 items-center gap-3.5 text-left"
/> >
<div class="min-w-0 flex-1 truncate text-sm font-medium text-neutral-200">{track.title}</div> <Cover
<form method="POST" action="?/addTrack" use:enhance={() => async ({ update }) => update()}> trackId={track.id}
hue={hueFor(track.id)}
size="small"
alt={track.title}
class="h-[40px] w-[40px] flex-shrink-0 rounded-md"
>
{#if player.current.id === track.id}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
<div class="min-w-0 flex-1 truncate text-sm font-medium text-neutral-200">{track.title}</div>
</button>
<div class="flex items-center justify-start gap-1.5 text-[12.5px] tabular-nums text-neutral-500">
<Headphones class="h-3.5 w-3.5" />
{Number(track.listensCount)}
</div>
<form
method="POST"
action="?/addTrack"
use:enhance={() => async ({ update }) => update()}
class="flex justify-end"
>
<input type="hidden" name="trackId" value={track.id} /> <input type="hidden" name="trackId" value={track.id} />
<button <button
type="submit" type="submit"
class="inline-flex items-center gap-1.5 rounded-lg border border-white/15 px-4 py-2 text-xs font-semibold text-white transition hover:border-emerald-500/50 hover:bg-emerald-500/10 hover:text-emerald-300" class="inline-flex items-center gap-1.5 rounded-lg border border-white/15 px-4 py-2 text-xs text-white transition hover:border-[var(--mf-accent)]/50 hover:text-[var(--mf-accent)]"
> >
<Plus class="h-3.5 w-3.5" strokeWidth={2.5} /> <Plus class="h-3.5 w-3.5" />
Añadir Añadir
</button> </button>
</form> </form>
@@ -226,9 +265,9 @@
if (e.key === 'Escape') editing = false; if (e.key === 'Escape') editing = false;
}} }}
> >
<div class="animate-pop w-full max-w-md rounded-2xl border border-white/10 bg-neutral-900 p-6 shadow-2xl"> <div class="animate-pop w-full max-w-2xl rounded-2xl bg-[var(--mf-bg)] p-6 shadow-2xl">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="font-display text-lg font-bold tracking-tight">Editar playlist</h2> <h2 class="text-lg font-bold tracking-tight">Editar playlist</h2>
<button <button
type="button" type="button"
onclick={() => (editing = false)} onclick={() => (editing = false)}
@@ -241,41 +280,85 @@
<form <form
method="POST" method="POST"
action="?/rename" action="?/rename"
use:enhance={() => async ({ update, result }) => { enctype="multipart/form-data"
await update({ reset: false }); use:enhance={() => {
if (result.type === 'success') editing = false; savingEdit = true;
return async ({ update, result }) => {
await update({ reset: false });
savingEdit = false;
if (result.type === 'success') editing = false;
};
}} }}
class="mt-5 space-y-4" class="mt-4 w-full max-w-4xl space-y-4"
> >
<div> <div class="flex items-stretch gap-8">
<label for="edit-name" class="mb-2 block text-sm font-medium text-neutral-300">Nombre</label> <div class="flex flex-col">
<input <span class="mb-3 block text-sm font-medium text-neutral-300">
id="edit-name" Portada <span class="text-neutral-500">(Opcional)</span>
name="name" </span>
type="text" <label
maxlength={100} class="relative grid h-52 w-52 cursor-pointer place-items-center overflow-hidden rounded-lg border border-transparent bg-[var(--mf-surface)] transition-colors hover:border-[var(--mf-accent)]/50"
required >
value={playlist.name} <input
class="w-full rounded-xl border border-white/10 bg-neutral-950 px-4 py-3 text-sm text-neutral-100 focus:border-emerald-500/60 focus:outline-none" type="file"
/> name="cover"
</div> accept="image/*"
<div> onchange={onEditCoverInput}
<label for="edit-desc" class="mb-2 block text-sm font-medium text-neutral-300">Descripción</label> class="absolute inset-0 cursor-pointer opacity-0"
<textarea aria-label="Seleccionar portada"
id="edit-desc" />
name="description" {#if editCoverPreview !== ''}
rows="3" <img src={editCoverPreview} alt="Portada" class="h-full w-full object-cover" />
maxlength={300} {:else if !editCoverFailed}
value={playlist.description} <img
class="w-full resize-none rounded-xl border border-white/10 bg-neutral-950 px-4 py-3 text-sm text-neutral-100 focus:border-emerald-500/60 focus:outline-none" src="/api/playlists/{playlist.id}/cover?size=medium"
></textarea> alt="Portada actual"
loading="lazy"
onerror={() => (editCoverFailed = true)}
class="h-full w-full object-cover"
/>
{:else}
<ImageIcon class="h-12 w-12 text-neutral-500" />
{/if}
</label>
</div>
<div class="flex flex-1 flex-col gap-4">
<div>
<label for="edit-name" class="mb-2 block text-sm font-medium text-neutral-300">Nombre</label>
<input
id="edit-name"
name="name"
type="text"
maxlength={100}
required
value={playlist.name}
class="w-full rounded-lg border border-transparent bg-[var(--mf-surface)] px-4 py-3 text-sm text-neutral-100 focus:border-[var(--mf-accent)]/50 focus:outline-none"
/>
</div>
<div class="flex min-h-0 flex-1 flex-col">
<label for="edit-desc" class="mb-2 block text-sm font-medium text-neutral-300">
Descripción <span class="text-neutral-500">(Opcional)</span>
</label>
<textarea
id="edit-desc"
name="description"
rows="3"
maxlength={300}
value={playlist.description}
class="w-full min-h-0 flex-1 resize-none rounded-lg border border-transparent bg-[var(--mf-surface)] px-4 py-3 text-sm text-neutral-100 focus:border-[var(--mf-accent)]/50 focus:outline-none"
></textarea>
</div>
</div>
</div> </div>
{#if form?.message} {#if form?.message}
<p class="rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200"> <p class="rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{form.message} {form.message}
</p> </p>
{/if} {/if}
<div class="flex items-center justify-end gap-3 pt-1"> <div class="flex items-center justify-end gap-3">
<button <button
type="button" type="button"
onclick={() => (editing = false)} onclick={() => (editing = false)}
@@ -285,9 +368,10 @@
</button> </button>
<button <button
type="submit" type="submit"
class="rounded-lg bg-[var(--mf-accent)] px-6 py-2.5 text-sm font-semibold text-neutral-950 transition hover:brightness-110" disabled={savingEdit}
class="rounded-lg bg-[var(--mf-accent)] px-6 py-2.5 text-sm font-semibold text-neutral-800 transition disabled:opacity-40"
> >
Guardar {savingEdit ? 'Guardando…' : 'Guardar'}
</button> </button>
</div> </div>
</form> </form>
@@ -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'
}
});
};