Design Prot Merge #1
@@ -0,0 +1,150 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import type { SessionUser } from '$lib/types';
|
||||||
|
import Home from '@lucide/svelte/icons/house';
|
||||||
|
import Search from '@lucide/svelte/icons/search';
|
||||||
|
import Library from '@lucide/svelte/icons/library-big';
|
||||||
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
|
import Infinity_ from '@lucide/svelte/icons/infinity';
|
||||||
|
import ArrowDown from '@lucide/svelte/icons/arrow-down';
|
||||||
|
import LogOut from '@lucide/svelte/icons/log-out';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: SessionUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { user }: Props = $props();
|
||||||
|
|
||||||
|
let menuOpen = $state(false);
|
||||||
|
let menuRef: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
let navBox: HTMLElement | undefined = $state();
|
||||||
|
let pillTop = $state(0);
|
||||||
|
let pillHeight = $state(0);
|
||||||
|
let pillVisible = $state(false);
|
||||||
|
let animate = $state(false);
|
||||||
|
|
||||||
|
function onDocumentClick(event: MouseEvent) {
|
||||||
|
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainLinks = [
|
||||||
|
{ href: '/', label: 'Inicio', icon: Home },
|
||||||
|
{ href: '/explore', label: 'Buscar', icon: Search },
|
||||||
|
{ href: '/library', label: 'Tu biblioteca', icon: Library },
|
||||||
|
{ href: '/playlists', label: 'Tus listas', icon: ListMusic }
|
||||||
|
];
|
||||||
|
|
||||||
|
const secondaryLinks = [
|
||||||
|
{ href: '/upload', label: 'Subir música', icon: Upload },
|
||||||
|
{ href: '/import', label: 'Importar', icon: Infinity_ },
|
||||||
|
{ href: '/downloads', label: 'Descargas', icon: ArrowDown }
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
return href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void page.url.pathname;
|
||||||
|
const box = navBox;
|
||||||
|
if (!box) return;
|
||||||
|
const active = box.querySelector<HTMLElement>('[data-active="true"]');
|
||||||
|
if (!active) {
|
||||||
|
pillVisible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const boxRect = box.getBoundingClientRect();
|
||||||
|
const rect = active.getBoundingClientRect();
|
||||||
|
pillTop = rect.top - boxRect.top;
|
||||||
|
pillHeight = rect.height;
|
||||||
|
pillVisible = true;
|
||||||
|
if (!animate) requestAnimationFrame(() => (animate = true));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet navLink(link: { href: string; label: string; icon: typeof Home })}
|
||||||
|
{@const active = isActive(link.href)}
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
data-active={active}
|
||||||
|
class="relative flex items-center gap-3 rounded-[10px] px-3 py-2.5 text-[14px] font-semibold transition-colors duration-100 {active
|
||||||
|
? 'text-[var(--mf-text)]'
|
||||||
|
: 'text-[var(--mf-text-2)] hover:bg-white/5 hover:text-[var(--mf-text)]'}"
|
||||||
|
>
|
||||||
|
<link.icon
|
||||||
|
class="h-[18px] w-[18px] flex-shrink-0"
|
||||||
|
strokeWidth={2}
|
||||||
|
style={active ? 'color:var(--mf-accent)' : ''}
|
||||||
|
/>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="sticky top-0 flex h-screen w-[264px] flex-shrink-0 flex-col gap-7 border-r border-[var(--mf-border)] px-[18px] py-[26px]"
|
||||||
|
>
|
||||||
|
<a href="/" class="px-3 font-display text-xl font-extrabold tracking-tight">Musify</a>
|
||||||
|
|
||||||
|
<div class="relative flex flex-col gap-7" bind:this={navBox}>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-x-0 z-0 overflow-hidden rounded-[10px] bg-white/10 {animate
|
||||||
|
? 'transition-[top,height] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]'
|
||||||
|
: ''}"
|
||||||
|
style="top:{pillTop}px; height:{pillHeight}px; opacity:{pillVisible ? 1 : 0}"
|
||||||
|
>
|
||||||
|
<span class="absolute inset-y-0 left-0 w-[3px] rounded-r-full bg-[var(--mf-accent)]"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="relative z-10 flex flex-col gap-1">
|
||||||
|
{#each mainLinks as link (link.href)}
|
||||||
|
{@render navLink(link)}
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="mx-2 border-t border-[var(--mf-border)]"></div>
|
||||||
|
|
||||||
|
<nav class="relative z-10 flex flex-col gap-1">
|
||||||
|
{#each secondaryLinks as link (link.href)}
|
||||||
|
{@render navLink(link)}
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative mt-auto" bind:this={menuRef}>
|
||||||
|
{#if menuOpen}
|
||||||
|
<div
|
||||||
|
class="absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-xl border border-[var(--mf-border)] bg-[var(--mf-elevated)] py-1.5 shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]"
|
||||||
|
>
|
||||||
|
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-full items-center gap-3 px-3 py-2.5 text-sm font-semibold text-[var(--mf-text-2)] transition hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<LogOut class="h-[18px] w-[18px]" strokeWidth={2} />
|
||||||
|
Salir
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (menuOpen = !menuOpen)}
|
||||||
|
class="flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition hover:bg-white/5"
|
||||||
|
>
|
||||||
|
{#if user.picture}
|
||||||
|
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="grid h-9 w-9 flex-shrink-0 place-items-center rounded-full bg-[var(--mf-surface-2)] text-xs font-semibold uppercase"
|
||||||
|
>
|
||||||
|
{user.name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="truncate text-[13px] font-semibold leading-[1.35]">{user.name}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<svelte:window onclick={onDocumentClick} />
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
const CONNECT_HOST = env.S3_UPLOAD_CONNECT_HOST || '127.0.0.1';
|
||||||
|
|
||||||
|
export async function putPresigned(
|
||||||
|
presignedUrl: string,
|
||||||
|
body: Buffer,
|
||||||
|
contentType: string
|
||||||
|
): Promise<void> {
|
||||||
|
const url = new URL(presignedUrl);
|
||||||
|
const signed = new Set(
|
||||||
|
(url.searchParams.get('X-Amz-SignedHeaders') ?? 'host').toLowerCase().split(';')
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers: Record<string, string | number> = {
|
||||||
|
Host: url.host,
|
||||||
|
'Content-Length': body.byteLength
|
||||||
|
};
|
||||||
|
if (signed.has('content-type')) headers['Content-Type'] = contentType;
|
||||||
|
if (signed.has('if-none-match')) headers['If-None-Match'] = '*';
|
||||||
|
|
||||||
|
const secure = url.protocol === 'https:';
|
||||||
|
const transport = secure ? https : http;
|
||||||
|
const port = url.port || (secure ? 443 : 80);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = transport.request(
|
||||||
|
{
|
||||||
|
host: CONNECT_HOST,
|
||||||
|
port,
|
||||||
|
method: 'PUT',
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
headers
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
let raw = '';
|
||||||
|
response.on('data', (chunk) => (raw += chunk));
|
||||||
|
response.on('end', () => {
|
||||||
|
const status = response.statusCode ?? 0;
|
||||||
|
if (status >= 200 && status < 300) resolve();
|
||||||
|
else reject(new Error(`S3 PUT ${status}: ${raw.slice(0, 200)}`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.on('error', reject);
|
||||||
|
request.write(body);
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ArrowDown from '@lucide/svelte/icons/arrow-down';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Descargas · Musify</title>
|
||||||
|
<meta name="description" content="Tus descargas en Musify." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="px-8 py-11">
|
||||||
|
<h1 class="font-display text-[28px] font-extrabold tracking-tight">Descargas</h1>
|
||||||
|
<p class="mb-7 mt-1.5 max-w-[560px] text-sm text-[var(--mf-text-2)]">
|
||||||
|
Escucha sin conexión lo que descargues.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="max-w-[560px] rounded-xl border border-[var(--mf-border)] bg-[var(--mf-surface)] px-6 py-10 text-center"
|
||||||
|
>
|
||||||
|
<ArrowDown class="mx-auto h-8 w-8 text-[var(--mf-text-3)]" strokeWidth={1.5} />
|
||||||
|
<p class="mt-4 text-sm text-[var(--mf-text-2)]">Todavía no tienes descargas</p>
|
||||||
|
<p class="mt-1.5 text-[12.5px] text-[var(--mf-text-4)]">
|
||||||
|
Las canciones que descargues aparecerán aquí.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
+36
-19
@@ -1,5 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Search from '@lucide/svelte/icons/search';
|
||||||
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import Cover from '$lib/components/ui/Cover.svelte';
|
||||||
|
import { HUES } from '$lib/theme/color';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -9,6 +13,12 @@
|
|||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
|
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
|
||||||
|
|
||||||
|
function hueFor(id: string) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
|
return HUES[hash % HUES.length];
|
||||||
|
}
|
||||||
|
|
||||||
function togglePlay(index: number) {
|
function togglePlay(index: number) {
|
||||||
const track = tracks.items[index];
|
const track = tracks.items[index];
|
||||||
if (player.current?.id === track.id) player.toggle();
|
if (player.current?.id === track.id) player.toggle();
|
||||||
@@ -29,27 +39,29 @@
|
|||||||
<meta name="description" content="Explora y busca canciones en Musify." />
|
<meta name="description" content="Explora y busca canciones en Musify." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section class="mx-auto max-w-6xl px-6 py-12">
|
<section class="px-8 py-12">
|
||||||
<h1 class="text-3xl font-bold tracking-tight sm:text-4xl">Explorar</h1>
|
<h1 class="text-3xl font-bold tracking-tight sm:text-4xl">Explorar</h1>
|
||||||
<p class="mt-2 text-neutral-400">Descubre canciones o busca por nombre.</p>
|
<p class="mt-2 text-neutral-400">Descubre canciones o busca por nombre.</p>
|
||||||
|
|
||||||
<form method="GET" class="mt-8 flex gap-3">
|
<form method="GET" class="mt-8 flex gap-3">
|
||||||
<div class="relative flex-1">
|
<div class="relative flex-1">
|
||||||
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-neutral-500"
|
<span
|
||||||
>🔍</span
|
class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-neutral-500"
|
||||||
>
|
>
|
||||||
|
<Search class="h-4 w-4" />
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
name="q"
|
name="q"
|
||||||
value={data.query}
|
value={data.query}
|
||||||
placeholder="Buscar por nombre…"
|
placeholder="Buscar por nombre…"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
class="w-full rounded-full border border-white/10 bg-white/5 py-3 pl-11 pr-4 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-emerald-500/50 focus:outline-none"
|
class="w-full rounded-lg border border-white/15 bg-neutral-950 py-3 pl-11 pr-4 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-emerald-500/60 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-full bg-emerald-500 px-6 py-3 text-sm font-semibold text-neutral-950 transition hover:bg-emerald-400"
|
class="rounded-lg bg-[var(--mf-accent)] px-6 py-3 text-sm font-semibold text-neutral-950 backdrop-blur-md transition hover:brightness-110 hover:shadow-[0_8px_24px_-8px] active:scale-[0.97]"
|
||||||
>
|
>
|
||||||
Buscar
|
Buscar
|
||||||
</button>
|
</button>
|
||||||
@@ -66,8 +78,8 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if tracks.items.length === 0}
|
{#if tracks.items.length === 0}
|
||||||
<div class="mt-16 rounded-2xl border border-white/5 bg-white/[0.03] p-12 text-center">
|
<div class="mt-16 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md">
|
||||||
<div class="text-4xl">🎵</div>
|
<Music class="mx-auto h-10 w-10 text-neutral-500" />
|
||||||
<p class="mt-4 text-neutral-300">
|
<p class="mt-4 text-neutral-300">
|
||||||
{#if data.query}
|
{#if data.query}
|
||||||
No hay canciones que coincidan con «{data.query}».
|
No hay canciones que coincidan con «{data.query}».
|
||||||
@@ -77,26 +89,31 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
<ul class="mt-8 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
|
||||||
{#each tracks.items as track, i (track.id)}
|
{#each tracks.items as track, i (track.id)}
|
||||||
<li
|
<li
|
||||||
class="group rounded-2xl border border-white/5 bg-white/[0.03] p-4 transition hover:border-emerald-500/30 hover:bg-white/[0.05]"
|
class="group animate-enter rounded-2xl bg-neutral-900/40 p-3 transition duration-150 hover:bg-neutral-900/70"
|
||||||
|
style="animation-delay:{i * 40}ms"
|
||||||
>
|
>
|
||||||
<div
|
<Cover
|
||||||
class="relative grid aspect-square place-items-center overflow-hidden rounded-xl bg-gradient-to-br from-emerald-500/20 to-emerald-500/5 text-4xl"
|
trackId={track.id}
|
||||||
|
hue={hueFor(track.id)}
|
||||||
|
size="large"
|
||||||
|
alt={track.title}
|
||||||
|
class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-12px_rgba(0,0,0,0.7)]"
|
||||||
>
|
>
|
||||||
🎵
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => togglePlay(i)}
|
onclick={() => togglePlay(i)}
|
||||||
aria-label={player.current?.id === track.id && player.isPlaying
|
aria-label={player.current?.id === track.id && player.isPlaying
|
||||||
? 'Pausar'
|
? 'Pausar'
|
||||||
: 'Reproducir'}
|
: 'Reproducir'}
|
||||||
class="absolute inset-0 grid place-items-center bg-neutral-950/40 opacity-0 transition group-hover:opacity-100 focus-visible:opacity-100"
|
class="absolute inset-0 grid place-items-center bg-gradient-to-t from-black/50 to-transparent opacity-0 transition group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
class:opacity-100={player.current?.id === track.id}
|
class:opacity-100={player.current?.id === track.id}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="grid h-12 w-12 place-items-center rounded-full bg-emerald-500 text-neutral-950 shadow-lg"
|
class="grid h-12 w-12 translate-y-2 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950 shadow-lg transition-transform group-hover:translate-y-0"
|
||||||
|
class:!translate-y-0={player.current?.id === track.id}
|
||||||
>
|
>
|
||||||
{#if player.current?.id === track.id && player.isPlaying}
|
{#if player.current?.id === track.id && player.isPlaying}
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" class="h-6 w-6">
|
<svg viewBox="0 0 24 24" fill="currentColor" class="h-6 w-6">
|
||||||
@@ -109,9 +126,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</Cover>
|
||||||
<h3 class="mt-3 truncate font-semibold" title={track.title}>{track.title}</h3>
|
<h3 class="mt-3 truncate px-1 font-semibold" title={track.title}>{track.title}</h3>
|
||||||
<p class="mt-1 text-xs text-neutral-500">
|
<p class="mt-0.5 px-1 text-xs text-neutral-500">
|
||||||
{dateFormatter.format(new Date(track.createdAt))}
|
{dateFormatter.format(new Date(track.createdAt))}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
@@ -123,7 +140,7 @@
|
|||||||
{#if tracks.hasPreviousPage}
|
{#if tracks.hasPreviousPage}
|
||||||
<a
|
<a
|
||||||
href={pageHref(currentPage - 1)}
|
href={pageHref(currentPage - 1)}
|
||||||
class="rounded-full border border-white/15 px-5 py-2 text-sm font-medium text-white transition hover:bg-white/5"
|
class="rounded-lg border border-white/20 px-5 py-2 text-sm font-medium text-white backdrop-blur-md transition hover:bg-white/5"
|
||||||
>
|
>
|
||||||
Anterior
|
Anterior
|
||||||
</a>
|
</a>
|
||||||
@@ -132,7 +149,7 @@
|
|||||||
{#if tracks.hasNextPage}
|
{#if tracks.hasNextPage}
|
||||||
<a
|
<a
|
||||||
href={pageHref(currentPage + 1)}
|
href={pageHref(currentPage + 1)}
|
||||||
class="rounded-full border border-white/15 px-5 py-2 text-sm font-medium text-white transition hover:bg-white/5"
|
class="rounded-lg border border-white/20 px-5 py-2 text-sm font-medium text-white backdrop-blur-md transition hover:bg-white/5"
|
||||||
>
|
>
|
||||||
Siguiente
|
Siguiente
|
||||||
</a>
|
</a>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Infinity_ from '@lucide/svelte/icons/infinity';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Importar · Musify</title>
|
||||||
|
<meta name="description" content="Importa tu música desde otros servicios." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="px-8 py-11">
|
||||||
|
<h1 class="font-display text-[28px] font-extrabold tracking-tight">Importar</h1>
|
||||||
|
<p class="mb-7 mt-1.5 max-w-[560px] text-sm text-[var(--mf-text-2)]">
|
||||||
|
Trae tu música desde otros servicios.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="max-w-[560px] rounded-xl border border-[var(--mf-border)] bg-[var(--mf-surface)] px-6 py-10 text-center"
|
||||||
|
>
|
||||||
|
<Infinity_ class="mx-auto h-8 w-8 text-[var(--mf-text-3)]" strokeWidth={1.5} />
|
||||||
|
<p class="mt-4 text-sm text-[var(--mf-text-2)]">Próximamente</p>
|
||||||
|
<p class="mt-1.5 text-[12.5px] text-[var(--mf-text-4)]">
|
||||||
|
Podrás importar tu música desde otras plataformas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||||
|
if (!locals.user) error(401, 'Inicia sesión.');
|
||||||
|
|
||||||
|
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||||
|
const { data, error: err } = await api.GET('/tracks/users/{userId}', {
|
||||||
|
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (err || !data) error(502, 'No se pudo cargar tu biblioteca.');
|
||||||
|
|
||||||
|
return { tracks: data };
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import { HUES } from '$lib/theme/color';
|
||||||
|
import Cover from '$lib/components/ui/Cover.svelte';
|
||||||
|
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
const items = $derived(data.tracks.items);
|
||||||
|
const total = $derived(Number(data.tracks.totalItemCount));
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
|
||||||
|
|
||||||
|
function hueFor(id: string) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
|
return HUES[hash % HUES.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlay(index: number) {
|
||||||
|
const track = items[index];
|
||||||
|
if (player.current.id === track.id) player.toggle();
|
||||||
|
else player.playQueue(items, index);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Tu biblioteca · Musify</title>
|
||||||
|
<meta name="description" content="Todas tus canciones en Musify." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="px-8 py-11">
|
||||||
|
<div class="flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="font-display text-[28px] font-extrabold tracking-tight">Tu biblioteca</h1>
|
||||||
|
<p class="mt-1.5 text-sm text-[var(--mf-text-2)]">
|
||||||
|
{total}
|
||||||
|
{total === 1 ? 'canción' : 'canciones'} en tu biblioteca.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/upload"
|
||||||
|
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]"
|
||||||
|
>
|
||||||
|
<Upload class="h-4 w-4" />
|
||||||
|
Subir música
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if items.length > 0}
|
||||||
|
<div class="mt-7 flex max-w-[900px] flex-col">
|
||||||
|
{#each items as track, i (track.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => togglePlay(i)}
|
||||||
|
class="animate-enter flex items-center gap-3.5 rounded-[10px] px-3 py-[11px] text-left transition duration-100 hover:bg-[var(--mf-surface-hover)] active:scale-[0.99]"
|
||||||
|
style="animation-delay:{i * 35}ms"
|
||||||
|
>
|
||||||
|
<Cover
|
||||||
|
trackId={track.id}
|
||||||
|
hue={hueFor(track.id)}
|
||||||
|
size="small"
|
||||||
|
alt={track.title}
|
||||||
|
class="h-[46px] w-[46px] flex-shrink-0 rounded-md"
|
||||||
|
>
|
||||||
|
{#if player.current.id === track.id}
|
||||||
|
<NowPlaying paused={!player.playing} />
|
||||||
|
{/if}
|
||||||
|
</Cover>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate text-[14.5px] font-semibold text-[var(--mf-text)]">
|
||||||
|
{track.title}
|
||||||
|
</div>
|
||||||
|
<div class="truncate text-[12.5px] text-[var(--mf-text-3)]">
|
||||||
|
{dateFormatter.format(new Date(track.createdAt))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="mt-7 max-w-[560px] rounded-xl border border-[var(--mf-border)] bg-[var(--mf-surface)] px-6 py-10 text-center"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-[var(--mf-text-2)]">Todavía no tienes canciones</p>
|
||||||
|
<a
|
||||||
|
href="/upload"
|
||||||
|
class="mt-4 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]"
|
||||||
|
>
|
||||||
|
<Upload class="h-4 w-4" />
|
||||||
|
Sube la primera
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
const MAX_NAME = 100;
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||||
|
if (!locals.user) error(401, 'Inicia sesión.');
|
||||||
|
|
||||||
|
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||||
|
const { data, error: err } = await api.GET('/playlists/users/{userId}', {
|
||||||
|
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (err || !data) error(502, 'No se pudieron cargar tus playlists.');
|
||||||
|
|
||||||
|
const covers = await Promise.all(
|
||||||
|
data.items.map(async (playlist) => {
|
||||||
|
const { data: tracks } = await api.GET('/playlists/{playlistId}/tracks', {
|
||||||
|
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 4 } }
|
||||||
|
});
|
||||||
|
return [playlist.id, tracks?.items.map((t) => t.id) ?? []] as const;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { playlists: data, trackIds: Object.fromEntries(covers) as Record<string, string[]> };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
create: async ({ request, locals, fetch }) => {
|
||||||
|
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const name = String(form.get('name') ?? '').trim();
|
||||||
|
const description = String(form.get('description') ?? '').trim();
|
||||||
|
|
||||||
|
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 });
|
||||||
|
const { data, error: err } = await api.POST('/playlists', {
|
||||||
|
body: { name, description, pictureIntentId: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (err || !data) return fail(502, { message: 'No se pudo crear la playlist.', name, description });
|
||||||
|
|
||||||
|
redirect(303, `/playlists/${data.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
|
import { HUES } from '$lib/theme/color';
|
||||||
|
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
const items = $derived(data.playlists.items);
|
||||||
|
const trackIds = $derived(data.trackIds);
|
||||||
|
|
||||||
|
let creating = $state(false);
|
||||||
|
let submitting = $state(false);
|
||||||
|
let name = $state('');
|
||||||
|
let description = $state('');
|
||||||
|
|
||||||
|
function hueFor(id: string) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
|
return HUES[hash % HUES.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
creating = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Tus listas · Musify</title>
|
||||||
|
<meta name="description" content="Tus listas en Musify." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="px-8 py-12">
|
||||||
|
<div class="flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight sm:text-4xl">Tus 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>
|
||||||
|
</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)}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<PlaylistArt
|
||||||
|
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)]"
|
||||||
|
/>
|
||||||
|
<div class="mt-3 truncate text-sm font-semibold text-neutral-100">{playlist.name}</div>
|
||||||
|
{#if playlist.description}
|
||||||
|
<div class="mt-0.5 truncate text-xs text-neutral-400">{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"
|
||||||
|
>
|
||||||
|
<span class="mx-auto grid h-14 w-14 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950">
|
||||||
|
<ListMusic class="h-7 w-7" />
|
||||||
|
</span>
|
||||||
|
<h3 class="mt-5 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>
|
||||||
|
<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]"
|
||||||
|
>
|
||||||
|
<Plus class="h-4 w-4" strokeWidth={2.5} />
|
||||||
|
Crear lista
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#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"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) creating = false;
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
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="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold tracking-tight">Nueva lista</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (creating = false)}
|
||||||
|
aria-label="Cerrar"
|
||||||
|
class="grid h-8 w-8 place-items-center rounded-full text-neutral-400 transition hover:bg-white/5 hover:text-white"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/create"
|
||||||
|
use:enhance={() => {
|
||||||
|
submitting = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update();
|
||||||
|
submitting = false;
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mt-5 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>
|
||||||
|
|
||||||
|
{#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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (creating = false)}
|
||||||
|
class="rounded-lg border border-white/15 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{submitting ? 'Creando…' : 'Crear'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
const MAX_NAME = 100;
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
|
||||||
|
if (!locals.user) error(401, 'Inicia sesión.');
|
||||||
|
|
||||||
|
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||||
|
|
||||||
|
const [playlistRes, tracksRes, libraryRes] = await Promise.all([
|
||||||
|
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
|
||||||
|
api.GET('/playlists/{playlistId}/tracks', {
|
||||||
|
params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } }
|
||||||
|
}),
|
||||||
|
api.GET('/tracks/users/{userId}', {
|
||||||
|
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 200 } }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (playlistRes.error || !playlistRes.data) error(404, 'Playlist no encontrada.');
|
||||||
|
|
||||||
|
const tracks = tracksRes.data?.items ?? [];
|
||||||
|
const inPlaylist = new Set(tracks.map((t) => t.id));
|
||||||
|
const library = (libraryRes.data?.items ?? []).filter((t) => !inPlaylist.has(t.id));
|
||||||
|
|
||||||
|
return { playlist: playlistRes.data, tracks, library };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
addTrack: async ({ request, params, locals, fetch }) => {
|
||||||
|
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
|
||||||
|
const trackId = String((await request.formData()).get('trackId') ?? '');
|
||||||
|
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||||
|
|
||||||
|
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||||
|
const { error: err } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', {
|
||||||
|
params: { path: { playlistId: params.id, trackId } }
|
||||||
|
});
|
||||||
|
if (err) return fail(502, { message: 'No se pudo añadir la canción.' });
|
||||||
|
return { added: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTrack: async ({ request, params, locals, fetch }) => {
|
||||||
|
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
|
||||||
|
const trackId = String((await request.formData()).get('trackId') ?? '');
|
||||||
|
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||||
|
|
||||||
|
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||||
|
const { error: err } = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', {
|
||||||
|
params: { path: { playlistId: params.id, trackId } }
|
||||||
|
});
|
||||||
|
if (err) return fail(502, { message: 'No se pudo quitar la canción.' });
|
||||||
|
return { removed: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
rename: async ({ request, params, locals, fetch }) => {
|
||||||
|
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
|
||||||
|
const form = await request.formData();
|
||||||
|
const name = String(form.get('name') ?? '').trim();
|
||||||
|
const description = String(form.get('description') ?? '').trim();
|
||||||
|
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 });
|
||||||
|
const { error: err } = await api.PUT('/playlists/{playlistId}', {
|
||||||
|
params: { path: { playlistId: params.id } },
|
||||||
|
body: { newName: name, newDescription: description, newPictureIntentId: null }
|
||||||
|
});
|
||||||
|
if (err) return fail(502, { message: 'No se pudo actualizar la playlist.' });
|
||||||
|
return { renamed: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ params, locals, fetch }) => {
|
||||||
|
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
|
||||||
|
|
||||||
|
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||||
|
const { error: err } = await api.DELETE('/playlists/{playlistId}', {
|
||||||
|
params: { path: { playlistId: params.id } }
|
||||||
|
});
|
||||||
|
if (err) return fail(502, { message: 'No se pudo eliminar la playlist.' });
|
||||||
|
redirect(303, '/playlists');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
|
import Play from '@lucide/svelte/icons/play';
|
||||||
|
import Pause from '@lucide/svelte/icons/pause';
|
||||||
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
|
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 { player } from '$lib/player/player.svelte';
|
||||||
|
import { HUES } from '$lib/theme/color';
|
||||||
|
import Cover from '$lib/components/ui/Cover.svelte';
|
||||||
|
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||||
|
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
const playlist = $derived(data.playlist);
|
||||||
|
const tracks = $derived(data.tracks);
|
||||||
|
const library = $derived(data.library);
|
||||||
|
|
||||||
|
let editing = $state(false);
|
||||||
|
let confirmingDelete = $state(false);
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
|
||||||
|
|
||||||
|
function hueFor(id: string) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
|
return HUES[hash % HUES.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentQueue = $derived(tracks.some((t) => t.id === player.current.id));
|
||||||
|
|
||||||
|
function playAll() {
|
||||||
|
if (tracks.length === 0) return;
|
||||||
|
if (isCurrentQueue) player.toggle();
|
||||||
|
else player.playQueue(tracks, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playFrom(index: number) {
|
||||||
|
const track = tracks[index];
|
||||||
|
if (player.current.id === track.id) player.toggle();
|
||||||
|
else player.playQueue(tracks, index);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{playlist.name} · Musify</title>
|
||||||
|
<meta name="description" content="Playlist {playlist.name} en Musify." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="px-8 py-8">
|
||||||
|
<a
|
||||||
|
href="/playlists"
|
||||||
|
class="group inline-flex items-center gap-2 text-sm font-medium text-neutral-400 transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="grid h-8 w-8 place-items-center rounded-lg border border-white/15 transition-all duration-200 group-hover:-translate-x-0.5 group-hover:border-emerald-500/60 group-hover:text-emerald-400"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
Tus listas
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-col gap-6 sm:flex-row sm:items-end">
|
||||||
|
<PlaylistArt
|
||||||
|
trackIds={tracks.map((t) => t.id)}
|
||||||
|
hue={hueFor(playlist.id)}
|
||||||
|
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">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-neutral-400">Lista</p>
|
||||||
|
<h1 class="mt-1.5 text-4xl font-bold tracking-tight sm:text-5xl">{playlist.name}</h1>
|
||||||
|
{#if playlist.description}
|
||||||
|
<p class="mt-3 max-w-2xl text-neutral-300">{playlist.description}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-3 text-sm text-neutral-500">
|
||||||
|
{tracks.length}
|
||||||
|
{tracks.length === 1 ? 'canción' : 'canciones'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-7 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={playAll}
|
||||||
|
disabled={tracks.length === 0}
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg bg-[var(--mf-accent)] px-7 py-3 text-sm font-semibold text-neutral-950 transition hover:brightness-110 hover:shadow-[0_10px_28px_-8px] active:scale-95 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{#if isCurrentQueue && player.playing}
|
||||||
|
<Pause class="h-4 w-4" fill="currentColor" />
|
||||||
|
Pausar
|
||||||
|
{:else}
|
||||||
|
<Play class="h-4 w-4" fill="currentColor" />
|
||||||
|
Reproducir
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (editing = true)}
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-white/15 px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<Pencil class="h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (confirmingDelete = true)}
|
||||||
|
aria-label="Eliminar playlist"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-white/15 px-5 py-3 text-sm font-semibold text-neutral-300 transition hover:border-red-500/40 hover:bg-red-500/10 hover:text-red-200"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tracks.length > 0}
|
||||||
|
<div class="mt-8 flex max-w-[900px] flex-col">
|
||||||
|
{#each tracks as track, i (track.id)}
|
||||||
|
<div
|
||||||
|
class="group flex items-center gap-3.5 rounded-[10px] px-3 py-[11px] transition hover:bg-neutral-900/60"
|
||||||
|
>
|
||||||
|
<button type="button" onclick={() => playFrom(i)} class="flex min-w-0 flex-1 items-center gap-3.5 text-left">
|
||||||
|
<Cover
|
||||||
|
trackId={track.id}
|
||||||
|
hue={hueFor(track.id)}
|
||||||
|
size="small"
|
||||||
|
alt={track.title}
|
||||||
|
class="h-[46px] w-[46px] flex-shrink-0 rounded-md"
|
||||||
|
>
|
||||||
|
{#if player.current.id === track.id}
|
||||||
|
<NowPlaying paused={!player.playing} />
|
||||||
|
{/if}
|
||||||
|
</Cover>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate text-[14.5px] font-semibold text-neutral-100">{track.title}</div>
|
||||||
|
<div class="truncate text-[12.5px] text-neutral-500">
|
||||||
|
{dateFormatter.format(new Date(track.createdAt))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<form method="POST" action="?/removeTrack" use:enhance={() => async ({ update }) => update()}>
|
||||||
|
<input type="hidden" name="trackId" value={track.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label="Quitar de la playlist"
|
||||||
|
class="grid h-8 w-8 place-items-center rounded-full text-neutral-500 opacity-0 transition hover:bg-white/5 hover:text-white group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</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]">
|
||||||
|
<h2 class="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()}>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Plus class="h-3.5 w-3.5" strokeWidth={2.5} />
|
||||||
|
Añadir
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if editing}
|
||||||
|
<div
|
||||||
|
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) editing = false;
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
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="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold tracking-tight">Editar playlist</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (editing = false)}
|
||||||
|
aria-label="Cerrar"
|
||||||
|
class="grid h-8 w-8 place-items-center rounded-full text-neutral-400 transition hover:bg-white/5 hover:text-white"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/rename"
|
||||||
|
use:enhance={() => async ({ update, result }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
if (result.type === 'success') editing = false;
|
||||||
|
}}
|
||||||
|
class="mt-5 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>
|
||||||
|
{#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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (editing = false)}
|
||||||
|
class="rounded-lg border border-white/15 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmingDelete}
|
||||||
|
<div
|
||||||
|
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) confirmingDelete = false;
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape') confirmingDelete = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="animate-pop w-full max-w-sm rounded-2xl border border-white/10 bg-neutral-900 p-6 shadow-2xl">
|
||||||
|
<h2 class="text-lg font-bold tracking-tight">¿Eliminar «{playlist.name}»?</h2>
|
||||||
|
<p class="mt-2 text-sm text-neutral-400">
|
||||||
|
Esta acción no se puede deshacer. Las canciones seguirán en tu biblioteca.
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (confirmingDelete = false)}
|
||||||
|
class="rounded-lg border border-white/15 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<form method="POST" action="?/delete">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-red-500 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-red-400"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { Actions } from './$types';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/server/api';
|
||||||
|
import { putPresigned } from '$lib/server/upload';
|
||||||
|
|
||||||
|
const MAX_TITLE = 100;
|
||||||
|
|
||||||
|
const AUDIO_TYPES: Record<string, string> = {
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
m4a: 'audio/mp4',
|
||||||
|
aac: 'audio/aac',
|
||||||
|
flac: 'audio/flac',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
opus: 'audio/opus'
|
||||||
|
};
|
||||||
|
|
||||||
|
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, table: Record<string, string>): string {
|
||||||
|
if (file.type) return file.type;
|
||||||
|
return table[extOf(file.name)] ?? 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request, locals, fetch }) => {
|
||||||
|
if (!locals.accessToken) {
|
||||||
|
return fail(401, { message: 'Inicia sesión para subir música.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const title = String(form.get('title') ?? '').trim();
|
||||||
|
const audio = form.get('audio');
|
||||||
|
const cover = form.get('cover');
|
||||||
|
|
||||||
|
if (title === '' || title.length > MAX_TITLE) {
|
||||||
|
return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' });
|
||||||
|
}
|
||||||
|
if (!(audio instanceof File) || audio.size === 0) {
|
||||||
|
return fail(400, { message: 'Selecciona un archivo de audio.' });
|
||||||
|
}
|
||||||
|
if (!(cover instanceof File) || cover.size === 0) {
|
||||||
|
return fail(400, { message: 'Selecciona una portada.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioContentType = contentTypeOf(audio, AUDIO_TYPES);
|
||||||
|
const pictureContentType = contentTypeOf(cover, IMAGE_TYPES);
|
||||||
|
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||||
|
|
||||||
|
const { data: urls, error: urlsError } = await api.POST('/tracks/upload-urls', {
|
||||||
|
body: {
|
||||||
|
pictureFileType: extOf(cover.name) || 'jpg',
|
||||||
|
pictureContentType,
|
||||||
|
audioFileType: extOf(audio.name) || 'mp3',
|
||||||
|
audioContentType,
|
||||||
|
expectedPictureSizeBytes: cover.size,
|
||||||
|
expectedAudioSizeBytes: audio.size
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (urlsError || !urls) {
|
||||||
|
return fail(502, { message: 'No se pudieron reservar las URLs de subida.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [audioBuffer, coverBuffer] = await Promise.all([
|
||||||
|
audio.arrayBuffer(),
|
||||||
|
cover.arrayBuffer()
|
||||||
|
]);
|
||||||
|
await Promise.all([
|
||||||
|
putPresigned(urls.audioUploadUrl, Buffer.from(audioBuffer), urls.audioContentType),
|
||||||
|
putPresigned(urls.pictureUploadUrl, Buffer.from(coverBuffer), urls.pictureContentType)
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
return fail(502, {
|
||||||
|
message: 'Falló la subida del archivo al almacenamiento.',
|
||||||
|
detail: err instanceof Error ? err.message : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: track, error: createError } = await api.POST('/tracks', {
|
||||||
|
body: {
|
||||||
|
title,
|
||||||
|
pictureIntentId: urls.pictureIntentId,
|
||||||
|
audioIntentId: urls.audioIntentId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createError || !track) {
|
||||||
|
return fail(502, { message: 'No se pudo crear la pista tras la subida.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, trackId: track.id, title: track.title };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import Headphones from '@lucide/svelte/icons/headphones';
|
||||||
|
import Music from '@lucide/svelte/icons/music';
|
||||||
|
import ImageIcon from '@lucide/svelte/icons/image';
|
||||||
|
import Check from '@lucide/svelte/icons/check';
|
||||||
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
|
|
||||||
|
type Status = 'idle' | 'uploading' | 'done' | 'error';
|
||||||
|
type StepState = 'idle' | 'active' | 'done';
|
||||||
|
|
||||||
|
const MAX_TITLE = 100;
|
||||||
|
|
||||||
|
let title = $state('');
|
||||||
|
let audioName = $state('');
|
||||||
|
let audioSize = $state(0);
|
||||||
|
let coverPreview = $state('');
|
||||||
|
let dragging = $state(false);
|
||||||
|
let status = $state<Status>('idle');
|
||||||
|
let acceptedTerms = $state(false);
|
||||||
|
let errorMsg = $state('');
|
||||||
|
let publishedTitle = $state('');
|
||||||
|
let audioInput = $state<HTMLInputElement>();
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ key: 'upload', label: 'Subir', hint: 'Portada y audio al almacenamiento' },
|
||||||
|
{ key: 'process', label: 'Procesar', hint: 'Transcodificado para streaming' },
|
||||||
|
{ key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const busy = $derived(status === 'uploading');
|
||||||
|
|
||||||
|
const canPublish = $derived(
|
||||||
|
title.trim() !== '' && audioName !== '' && coverPreview !== '' && acceptedTerms && !busy
|
||||||
|
);
|
||||||
|
|
||||||
|
const stepStates = $derived.by<StepState[]>(() => {
|
||||||
|
if (status === 'done') return ['done', 'done', 'done'];
|
||||||
|
if (status === 'uploading') return ['active', 'idle', 'idle'];
|
||||||
|
return ['idle', 'idle', 'idle'];
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropClass = $derived(
|
||||||
|
dragging || audioName !== ''
|
||||||
|
? 'border-emerald-500/60'
|
||||||
|
: 'border-white/15 hover:border-emerald-500/60'
|
||||||
|
);
|
||||||
|
|
||||||
|
function stepClass(s: StepState) {
|
||||||
|
if (s === 'active') return 'border-emerald-500/40 bg-emerald-500/5';
|
||||||
|
if (s === 'done') return 'border-emerald-500/40';
|
||||||
|
return 'border-white/15';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (bytes === 0) return '';
|
||||||
|
const mb = bytes / (1024 * 1024);
|
||||||
|
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAudio(file: File) {
|
||||||
|
audioName = file.name;
|
||||||
|
audioSize = file.size;
|
||||||
|
if (title.trim() === '') title = file.name.replace(/\.[^.]+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioInput(event: Event) {
|
||||||
|
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||||
|
if (file) pickAudio(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
dragging = false;
|
||||||
|
const file = event.dataTransfer?.files?.[0];
|
||||||
|
if (!file || !audioInput) return;
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(file);
|
||||||
|
audioInput.files = dt.files;
|
||||||
|
pickAudio(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCoverInput(event: Event) {
|
||||||
|
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||||
|
if (file) coverPreview = URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
title = '';
|
||||||
|
audioName = '';
|
||||||
|
audioSize = 0;
|
||||||
|
coverPreview = '';
|
||||||
|
status = 'idle';
|
||||||
|
acceptedTerms = false;
|
||||||
|
errorMsg = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Subir música · Musify</title>
|
||||||
|
<meta name="description" content="Sube tus canciones a Musify: portada, audio y título." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="px-8 pt-8 pb-16 animate-[fade-up_0.45s_cubic-bezier(0.16,1,0.3,1)]">
|
||||||
|
<a
|
||||||
|
href="/library"
|
||||||
|
class="group inline-flex items-center gap-2 text-sm font-medium text-neutral-400 transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="grid h-8 w-8 place-items-center rounded-lg border border-white/15 transition-all duration-200 group-hover:-translate-x-0.5 group-hover:border-emerald-500/60 group-hover:text-emerald-400"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
Volver a la biblioteca
|
||||||
|
</a>
|
||||||
|
<h1 class="mt-6 text-4xl font-bold tracking-tight sm:text-5xl">Subir música</h1>
|
||||||
|
<p class="mt-4 text-lg text-neutral-400">
|
||||||
|
Añade una canción con su portada y título. Nosotros la procesamos para streaming.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol class="mt-10 grid gap-4 sm:grid-cols-3">
|
||||||
|
{#each steps as step, i (step.key)}
|
||||||
|
<li class="rounded-xl border p-5 transition {stepClass(stepStates[i])}">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="grid h-8 w-8 place-items-center rounded-full bg-[var(--mf-accent)] text-sm font-bold text-neutral-950"
|
||||||
|
>
|
||||||
|
{#if stepStates[i] === 'done'}
|
||||||
|
<Check class="h-4 w-4" strokeWidth={2.5} />
|
||||||
|
{:else}
|
||||||
|
{i + 1}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="text-base font-semibold">{step.label}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-sm text-neutral-500">{step.hint}</p>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{#if status === 'done'}
|
||||||
|
<div class="mt-10 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-12 text-center">
|
||||||
|
<span
|
||||||
|
class="mx-auto grid h-16 w-16 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950"
|
||||||
|
>
|
||||||
|
<Check class="h-8 w-8" strokeWidth={2.5} />
|
||||||
|
</span>
|
||||||
|
<h2 class="mt-6 text-2xl font-bold tracking-tight">¡Subida!</h2>
|
||||||
|
<p class="mt-3 text-lg text-neutral-300">
|
||||||
|
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
|
||||||
|
unos momentos.
|
||||||
|
</p>
|
||||||
|
<div class="mt-8 flex items-center justify-center gap-4">
|
||||||
|
<a
|
||||||
|
href="/library"
|
||||||
|
class="rounded-lg bg-[var(--mf-accent)] px-8 py-3.5 text-base font-semibold text-neutral-950 transition hover:brightness-110"
|
||||||
|
>
|
||||||
|
Ir a la biblioteca
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={reset}
|
||||||
|
class="rounded-lg border border-white/20 px-8 py-3.5 text-base font-semibold text-white backdrop-blur-md transition hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Subir otra
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
use:enhance={() => {
|
||||||
|
status = 'uploading';
|
||||||
|
errorMsg = '';
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
publishedTitle = title;
|
||||||
|
status = 'done';
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
status = 'error';
|
||||||
|
errorMsg = (result.data?.message as string) ?? 'No se pudo subir la canción.';
|
||||||
|
} else {
|
||||||
|
status = 'error';
|
||||||
|
errorMsg = 'Error inesperado durante la subida.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mt-10 space-y-8"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
ondragover={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
}}
|
||||||
|
ondragleave={() => (dragging = false)}
|
||||||
|
ondrop={onDrop}
|
||||||
|
class="relative flex min-h-72 flex-col items-center justify-center rounded-xl border border-dashed bg-neutral-950 p-10 text-center transition {dropClass}"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
bind:this={audioInput}
|
||||||
|
type="file"
|
||||||
|
name="audio"
|
||||||
|
accept="audio/*"
|
||||||
|
onchange={onAudioInput}
|
||||||
|
class="absolute inset-0 cursor-pointer opacity-0"
|
||||||
|
aria-label="Seleccionar archivo de audio"
|
||||||
|
/>
|
||||||
|
<span class="grid h-20 w-20 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950">
|
||||||
|
{#if audioName === ''}
|
||||||
|
<Headphones class="h-9 w-9" />
|
||||||
|
{:else}
|
||||||
|
<Music class="h-9 w-9" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if audioName === ''}
|
||||||
|
<p class="mt-5 text-lg font-medium text-neutral-200">
|
||||||
|
Arrastra tu audio aquí o haz clic para elegir
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-neutral-500">MP3, FLAC, WAV…</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-5 max-w-full truncate text-lg font-medium text-neutral-100">{audioName}</p>
|
||||||
|
<p class="mt-2 text-sm text-neutral-500">{formatSize(audioSize)} · Listo para subir</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid items-start gap-8 sm:grid-cols-[auto_1fr]">
|
||||||
|
<div>
|
||||||
|
<span class="mb-3 block text-sm font-medium text-neutral-300">Portada</span>
|
||||||
|
<label
|
||||||
|
class="relative grid h-44 w-44 cursor-pointer place-items-center overflow-hidden rounded-xl border border-white/15 bg-neutral-950 transition hover:border-emerald-500/40"
|
||||||
|
>
|
||||||
|
<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-col">
|
||||||
|
<label for="title" class="mb-3 block text-sm font-medium text-neutral-300">Título</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
bind:value={title}
|
||||||
|
maxlength={MAX_TITLE}
|
||||||
|
placeholder="Nombre de la canción"
|
||||||
|
class="w-full rounded-lg border border-white/15 bg-neutral-950 px-5 py-4 text-base text-neutral-100 placeholder:text-neutral-500 focus:border-emerald-500/60 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div class="mt-3 flex items-center justify-between gap-3 text-sm text-neutral-500">
|
||||||
|
<p>Se usa como nombre público de la pista en el catálogo.</p>
|
||||||
|
<span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-3 text-sm text-neutral-300">
|
||||||
|
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={acceptedTerms}
|
||||||
|
class="peer h-5 w-5 cursor-pointer appearance-none rounded border border-white/15 bg-neutral-950 transition checked:border-emerald-500 checked:bg-[var(--mf-accent)] focus-visible:border-emerald-500/60 focus-visible:outline-none"
|
||||||
|
/>
|
||||||
|
<Check
|
||||||
|
class="pointer-events-none absolute h-3.5 w-3.5 text-neutral-950 opacity-0 transition peer-checked:opacity-100"
|
||||||
|
strokeWidth={3}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Acepto los
|
||||||
|
<a
|
||||||
|
href="/terms"
|
||||||
|
class="text-emerald-400 underline underline-offset-2 transition hover:text-emerald-300"
|
||||||
|
>
|
||||||
|
términos y condiciones
|
||||||
|
</a>
|
||||||
|
al subir {title.trim() !== '' ? `«${title}»` : 'esta canción'}.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if status === 'uploading'}
|
||||||
|
<div class="rounded-xl border border-white/15 bg-neutral-950 p-6">
|
||||||
|
<div class="flex items-center justify-between text-base">
|
||||||
|
<span class="font-medium text-neutral-200">Subiendo y creando la pista…</span>
|
||||||
|
<span class="text-neutral-500">un momento</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 h-2.5 overflow-hidden rounded-full bg-white/10">
|
||||||
|
<div class="h-full w-full animate-pulse rounded-full bg-[var(--mf-accent)]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if status === 'error'}
|
||||||
|
<div class="rounded-xl border border-red-500/40 bg-red-500/10 p-5 text-sm text-red-200">
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-4">
|
||||||
|
<a
|
||||||
|
href="/explore"
|
||||||
|
class="rounded-lg border border-white/20 px-8 py-3.5 text-base font-semibold text-white backdrop-blur-md transition hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canPublish}
|
||||||
|
class="cursor-pointer rounded-lg bg-[var(--mf-accent)] px-8 py-3.5 text-base font-semibold text-neutral-950 backdrop-blur-md transition hover:brightness-110 hover:shadow-[0_10px_28px_-8px] active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:shadow-none"
|
||||||
|
>
|
||||||
|
Publicar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = ({ locals }) => ({
|
const PUBLIC_PATHS = new Set(['/login']);
|
||||||
user: locals.user
|
|
||||||
});
|
export const load: LayoutServerLoad = ({ locals, url }) => {
|
||||||
|
if (!locals.user && !PUBLIC_PATHS.has(url.pathname)) {
|
||||||
|
redirect(302, `/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
|
||||||
|
}
|
||||||
|
return { user: locals.user };
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,20 +1,75 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import '@fontsource-variable/manrope/index.css';
|
||||||
|
import '@fontsource-variable/sora/index.css';
|
||||||
|
import '$lib/theme/theme.css';
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import Navbar from '$lib/components/Navbar.svelte';
|
|
||||||
import Footer from '$lib/components/Footer.svelte';
|
|
||||||
import PlayerBar from '$lib/components/player/PlayerBar.svelte';
|
import PlayerBar from '$lib/components/player/PlayerBar.svelte';
|
||||||
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
|
|
||||||
|
const isAuthPage = $derived(page.url.pathname === '/login');
|
||||||
|
const hasTrack = $derived(player.currentId !== null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
document.documentElement.classList.toggle('home', page.url.pathname === '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseAccent(value: string): { c: number; h: number } | null {
|
||||||
|
const m = /oklch\(\s*[\d.]+%?\s+([\d.]+)\s+([\d.]+)/.exec(value);
|
||||||
|
return m ? { c: parseFloat(m[1]), h: parseFloat(m[2]) } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayed = { c: 0.17, h: 145 };
|
||||||
|
let rafId = 0;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const target = parseAccent(player.accent);
|
||||||
|
if (!target) {
|
||||||
|
document.documentElement.style.setProperty('--mf-accent', player.accent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
const from = { ...displayed };
|
||||||
|
let dh = target.h - from.h;
|
||||||
|
if (dh > 180) dh -= 360;
|
||||||
|
else if (dh < -180) dh += 360;
|
||||||
|
const start = performance.now();
|
||||||
|
const duration = 350;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const t = Math.min(1, (now - start) / duration);
|
||||||
|
const e = 1 - Math.pow(1 - t, 3);
|
||||||
|
const c = from.c + (target.c - from.c) * e;
|
||||||
|
const h = (((from.h + dh * e) % 360) + 360) % 360;
|
||||||
|
displayed = { c, h };
|
||||||
|
document.documentElement.style.setProperty('--mf-accent', `oklch(72% ${c.toFixed(3)} ${h.toFixed(1)})`);
|
||||||
|
if (t < 1) rafId = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||||
|
|
||||||
<div class="flex min-h-screen flex-col bg-neutral-950 text-neutral-100 antialiased">
|
{#if isAuthPage}
|
||||||
<Navbar user={data.user} />
|
<div class="min-h-screen bg-[var(--mf-bg)] text-[var(--mf-text)] antialiased">
|
||||||
<main class="flex-1">{@render children()}</main>
|
{@render children()}
|
||||||
<Footer />
|
|
||||||
<div class:pb-20={player.current}></div>
|
|
||||||
<PlayerBar />
|
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex min-h-screen bg-[var(--mf-bg)] text-[var(--mf-text)] antialiased">
|
||||||
|
<Sidebar user={data.user!} />
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<main class="flex-1 transition-[padding] {hasTrack ? 'pb-[92px]' : 'pb-0'}">
|
||||||
|
{#key page.url.pathname}
|
||||||
|
<div class="animate-fade">{@render children()}</div>
|
||||||
|
{/key}
|
||||||
|
</main>
|
||||||
|
{#if hasTrack}
|
||||||
|
<PlayerBar />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = ({ locals, url }) => {
|
||||||
|
if (locals.user) {
|
||||||
|
redirect(302, url.searchParams.get('returnTo') ?? '/');
|
||||||
|
}
|
||||||
|
return { returnTo: url.searchParams.get('returnTo') ?? '/' };
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
const returnParam = $derived(`returnTo=${encodeURIComponent(data.returnTo)}`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Iniciar sesión · Musify</title>
|
||||||
|
<meta name="description" content="Inicia sesión o crea una cuenta en Musify." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="grid min-h-screen place-items-center bg-black px-6 text-[var(--mf-text)]">
|
||||||
|
<div class="flex w-full max-w-[440px] flex-col items-center gap-9 text-center">
|
||||||
|
<div class="font-display text-3xl font-extrabold tracking-tight">Musify</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="font-display text-[36px] font-extrabold tracking-tight">Tu música. Sin límites.</h1>
|
||||||
|
<p class="mt-3 text-base text-[var(--mf-text-2)]">Inicia sesión para continuar.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-5">
|
||||||
|
<a
|
||||||
|
href={`/auth/login?${returnParam}`}
|
||||||
|
data-sveltekit-reload
|
||||||
|
class="rounded-lg px-6 py-3.5 text-base font-semibold transition hover:brightness-110"
|
||||||
|
style="background:var(--mf-accent);color:#000"
|
||||||
|
>
|
||||||
|
Iniciar Sesión
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`/auth/login?mode=register&${returnParam}`}
|
||||||
|
data-sveltekit-reload
|
||||||
|
class="rounded-lg bg-[var(--mf-surface-2)] px-6 py-3.5 text-base font-semibold transition hover:bg-[var(--mf-surface-hover)]"
|
||||||
|
>
|
||||||
|
Crear Cuenta
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user