Design Prot Merge #1
@@ -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);
|
||||
</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}">
|
||||
{#each trackIds.slice(0, 4) as id (id)}
|
||||
<Cover trackId={id} hue={hueFor(id)} size="small" class="h-full w-full" />
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -38,48 +62,54 @@
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreate}
|
||||
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]"
|
||||
>
|
||||
<Plus class="h-4 w-4" strokeWidth={2.5} />
|
||||
Crear lista
|
||||
</button>
|
||||
{#if items.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if items.length > 0}
|
||||
<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
|
||||
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
|
||||
playlistId={playlist.id}
|
||||
trackIds={trackIds[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}
|
||||
<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}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<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" />
|
||||
</span>
|
||||
<h3 class="mt-5 font-display text-xl 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>
|
||||
<h3 class="mt-5 font-display text-2xl font-bold tracking-tight">Todavía no tienes listas</h3>
|
||||
<p class="mt-2 text-neutral-400">Crea la primera y añádele canciones de tu biblioteca.</p>
|
||||
<button
|
||||
type="button"
|
||||
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} />
|
||||
Crear lista
|
||||
@@ -90,8 +120,8 @@
|
||||
|
||||
{#if creating}
|
||||
<div
|
||||
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
role="button"
|
||||
class="animate-fade fixed inset-0 z-100 grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
tabindex="0"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) creating = false;
|
||||
@@ -100,9 +130,9 @@
|
||||
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">
|
||||
<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
|
||||
type="button"
|
||||
onclick={() => (creating = false)}
|
||||
@@ -116,6 +146,7 @@
|
||||
<form
|
||||
method="POST"
|
||||
action="?/create"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update }) => {
|
||||
@@ -123,34 +154,62 @@
|
||||
submitting = false;
|
||||
};
|
||||
}}
|
||||
class="mt-5 space-y-4"
|
||||
class="mt-4 w-full max-w-4xl space-y-4"
|
||||
>
|
||||
<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
|
||||
bind:value={name}
|
||||
placeholder="Mi nueva lista"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<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"
|
||||
rows="3"
|
||||
maxlength={300}
|
||||
bind:value={description}
|
||||
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"
|
||||
></textarea>
|
||||
<div class="flex items-stretch gap-8">
|
||||
<div class="flex flex-col">
|
||||
<span class="mb-3 block text-sm font-medium text-neutral-300">
|
||||
Portada <span class="text-neutral-500">(Opcional)</span>
|
||||
</span>
|
||||
<label
|
||||
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 "
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
name="cover"
|
||||
accept="image/*"
|
||||
onchange={onCoverInput}
|
||||
class="absolute inset-0 cursor-pointer opacity-0"
|
||||
aria-label="Seleccionar portada"
|
||||
/>
|
||||
{#if coverPreview !== ''}
|
||||
<img src={coverPreview} alt="Portada" 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="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>
|
||||
|
||||
{#if form?.message}
|
||||
@@ -159,7 +218,7 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-1">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (creating = false)}
|
||||
@@ -169,8 +228,8 @@
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
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"
|
||||
disabled={submitting || name.trim() === ''}
|
||||
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'}
|
||||
</button>
|
||||
|
||||
@@ -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 ({ 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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -67,8 +88,10 @@
|
||||
|
||||
<div class="mt-6 flex flex-col gap-6 sm:flex-row sm:items-end">
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
trackIds={tracks.map((t) => 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)]"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -101,7 +124,7 @@
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
@@ -120,7 +143,7 @@
|
||||
{#if tracks.length > 0}
|
||||
<div class="mt-8">
|
||||
<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>Título</span>
|
||||
@@ -176,34 +199,50 @@
|
||||
{/each}
|
||||
</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 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>
|
||||
<div class="mt-4 flex flex-col">
|
||||
{#each library as track (track.id)}
|
||||
<div class="flex items-center gap-3.5 rounded-[10px] px-3 py-[10px] transition hover:bg-neutral-900/60">
|
||||
<Cover
|
||||
trackId={track.id}
|
||||
hue={hueFor(track.id)}
|
||||
size="small"
|
||||
alt={track.title}
|
||||
class="h-[40px] w-[40px] flex-shrink-0 rounded-md"
|
||||
/>
|
||||
<div class="min-w-0 flex-1 truncate text-sm font-medium text-neutral-200">{track.title}</div>
|
||||
<form method="POST" action="?/addTrack" use:enhance={() => async ({ update }) => update()}>
|
||||
{#each library as track, i (track.id)}
|
||||
<div
|
||||
class="group grid grid-cols-[1fr_140px_110px] items-center gap-4 rounded-[10px] px-3 py-[10px] transition hover:bg-neutral-900/60"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playFromLibrary(i)}
|
||||
class="flex min-w-0 items-center gap-3.5 text-left"
|
||||
>
|
||||
<Cover
|
||||
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} />
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</form>
|
||||
@@ -226,9 +265,9 @@
|
||||
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">
|
||||
<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
|
||||
type="button"
|
||||
onclick={() => (editing = false)}
|
||||
@@ -241,41 +280,85 @@
|
||||
<form
|
||||
method="POST"
|
||||
action="?/rename"
|
||||
use:enhance={() => async ({ update, result }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') editing = false;
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
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>
|
||||
<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-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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="edit-desc" class="mb-2 block text-sm font-medium text-neutral-300">Descripción</label>
|
||||
<textarea
|
||||
id="edit-desc"
|
||||
name="description"
|
||||
rows="3"
|
||||
maxlength={300}
|
||||
value={playlist.description}
|
||||
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"
|
||||
></textarea>
|
||||
<div class="flex items-stretch gap-8">
|
||||
<div class="flex flex-col">
|
||||
<span class="mb-3 block text-sm font-medium text-neutral-300">
|
||||
Portada <span class="text-neutral-500">(Opcional)</span>
|
||||
</span>
|
||||
<label
|
||||
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"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
name="cover"
|
||||
accept="image/*"
|
||||
onchange={onEditCoverInput}
|
||||
class="absolute inset-0 cursor-pointer opacity-0"
|
||||
aria-label="Seleccionar portada"
|
||||
/>
|
||||
{#if editCoverPreview !== ''}
|
||||
<img src={editCoverPreview} alt="Portada" class="h-full w-full object-cover" />
|
||||
{:else if !editCoverFailed}
|
||||
<img
|
||||
src="/api/playlists/{playlist.id}/cover?size=medium"
|
||||
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>
|
||||
|
||||
{#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">
|
||||
{form.message}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex items-center justify-end gap-3 pt-1">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editing = false)}
|
||||
@@ -285,9 +368,10 @@
|
||||
</button>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</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'
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user