This commit is contained in:
2026-07-18 23:29:57 +02:00
parent 72f42369a0
commit 99c4bb8408
109 changed files with 5134 additions and 2163 deletions
+15
View File
@@ -0,0 +1,15 @@
export function appendUnique<T>(
current: T[],
incoming: T[],
keyOf: (item: T) => string | number
): T[] {
const seen = new Set(current.map(keyOf));
const added: T[] = [];
for (const item of incoming) {
const key = keyOf(item);
if (seen.has(key)) continue;
seen.add(key);
added.push(item);
}
return added.length > 0 ? [...current, ...added] : current;
}
@@ -1,8 +0,0 @@
<footer class="border-t border-white/10 py-8">
<div
class="flex flex-col items-center justify-between gap-4 px-8 text-sm text-neutral-500 sm:flex-row"
>
<p>© {new Date().getFullYear()} Musify · Streaming de música.</p>
<p class="text-neutral-600">Tu música, siempre contigo.</p>
</div>
</footer>
-56
View File
@@ -1,56 +0,0 @@
<script lang="ts">
import type { SessionUser } from '$lib/types';
interface Props {
user: SessionUser | null;
}
let { user }: Props = $props();
</script>
<header class="sticky top-0 z-50 border-b border-white/10 bg-neutral-950/80 backdrop-blur">
<nav class="flex items-center justify-between gap-4 px-8 py-4">
<a href="/" class="text-lg tracking-tight text-neutral-100 transition hover:text-white">
Musify
</a>
<div class="flex items-center gap-6">
{#if user}
<a href="/explore" class="flex items-center gap-2 text-sm text-neutral-200">
{#if user.picture}
<img src={user.picture} alt="" class="h-7 w-7 rounded-full object-cover" />
{:else}
<span
class="grid h-7 w-7 place-items-center rounded-full bg-neutral-700 text-xs font-semibold uppercase"
>
{user.name.charAt(0)}
</span>
{/if}
<span class="hidden sm:inline">{user.name}</span>
</a>
<form method="POST" action="/logout">
<button
class="rounded-full border border-white/10 px-4 py-2 text-sm text-white/65 transition hover:bg-white/2.5"
>
Salir
</button>
</form>
{:else}
<a
href="/login"
data-sveltekit-reload
class="text-sm font-medium text-neutral-300 transition hover:text-white"
>
Iniciar sesión
</a>
<a
href="/login?mode=register"
data-sveltekit-reload
class="rounded-full bg-[var(--mf-accent)] px-4 py-2 text-sm font-semibold text-neutral-950 backdrop-blur-md transition hover:brightness-110"
>
Registrarse
</a>
{/if}
</div>
</nav>
</header>
+20 -20
View File
@@ -3,8 +3,8 @@
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 Folder from '@lucide/svelte/icons/folder';
import Music from '@lucide/svelte/icons/music';
import Upload from '@lucide/svelte/icons/upload';
import LogOut from '@lucide/svelte/icons/log-out';
@@ -30,11 +30,11 @@
const mainLinks = [
{ href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Buscar', icon: Search },
{ href: '/playlists', label: 'Listas', icon: ListMusic }
{ href: '/playlists', label: 'Listas', icon: Music }
];
const secondaryLinks = [
{ href: '/library', label: 'Canciones subidas', icon: Library },
{ href: '/library', label: 'Canciones subidas', icon: Folder },
{ href: '/upload', label: 'Subir música', icon: Upload }
];
@@ -65,32 +65,32 @@
<a
href={link.href}
data-active={active}
class="relative flex items-center gap-3 rounded-[10px] px-3 py-2.5 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)]'}"
class="relative flex items-center gap-4 rounded-control px-4 py-3 transition-colors duration-100 {active
? 'text-fg'
: 'text-fg-3 hover:bg-hover hover:text-fg'}"
>
<link.icon
class="h-[18px] w-[18px] flex-shrink-0"
strokeWidth={2}
style={active ? 'color:var(--mf-accent)' : ''}
class="h-6 w-6 shrink-0"
style={active ? 'color:var(--mf-accent-60)' : ''}
/>
{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]"
class="sticky top-0 flex h-screen flex-shrink-0 flex-col gap-7 border-r border-line px-[18px] py-[26px]"
style="width:var(--mf-sidebar-w)"
>
<a href="/" class="px-3 font-display text-xl font-extrabold tracking-tight">Musify</a>
<a href="/" class="px-3 text-xl font-extrabold tracking-wide">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
class="pointer-events-none absolute inset-x-0 z-0 overflow-hidden rounded-control bg-surface-hover {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>
<span class="absolute inset-y-0 left-0 w-[3px] rounded-r-full" style="background:var(--mf-accent-60)"></span>
</div>
<nav class="relative z-10 flex flex-col gap-1">
@@ -99,7 +99,7 @@
{/each}
</nav>
<div class="mx-2 border-t border-[var(--mf-border)]"></div>
<div class="mx-2 border-t border-line"></div>
<nav class="relative z-10 flex flex-col gap-1">
{#each secondaryLinks as link (link.href)}
@@ -111,12 +111,12 @@
<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)]"
class="absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-panel border border-line bg-elevated py-1.5 shadow-menu"
>
<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"
class="flex w-full items-center gap-3 px-3 py-2.5 text-base font-semibold text-fg-2 transition hover:bg-hover"
>
<LogOut class="h-[18px] w-[18px]" strokeWidth={2} />
Salir
@@ -127,18 +127,18 @@
<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"
class="flex w-full items-center gap-2.5 rounded-control px-3 py-2.5 text-left transition hover:bg-hover"
>
{#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"
class="grid h-9 w-9 flex-shrink-0 place-items-center rounded-full bg-surface-2 text-sm font-semibold uppercase"
>
{user.name.charAt(0)}
</span>
{/if}
<span class="truncate text-[13px] font-semibold leading-[1.35]">{user.name}</span>
<span class="truncate text-base font-semibold leading-[1.35]">{user.name}</span>
</button>
</div>
</aside>
@@ -0,0 +1,129 @@
<script lang="ts" module>
import type { ApiTrackLike } from '$lib/player/player.svelte';
import type { YouTubeSong } from '$lib/types';
export type ContextMenuTarget =
| { kind: 'youtube'; song: YouTubeSong }
| { kind: 'local'; track: ApiTrackLike };
export type ContextMenuState = ContextMenuTarget & { x: number; y: number; openLeft: boolean };
const MENU_WIDTH = 208;
const SUBMENU_WIDTH = 224;
export function contextMenuStateFor(
event: MouseEvent,
target: ContextMenuTarget
): ContextMenuState {
event.preventDefault();
return {
...target,
x: Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8),
y: Math.min(event.clientY, window.innerHeight - 60),
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
};
}
</script>
<script lang="ts">
import { enhance } from '$app/forms';
import ListPlus from '@lucide/svelte/icons/list-plus';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
menu: ContextMenuState;
playlists: { id: string; name: string }[];
onClose: () => void;
onAddToQueue: () => void;
}
let { menu, playlists, onClose, onAddToQueue }: Props = $props();
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<div
class="fixed inset-0 z-30"
role="presentation"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
></div>
<div
class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
style="left:{menu.x}px; top:{menu.y}px;"
>
<button
type="button"
onclick={onAddToQueue}
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover"
>
<ListPlus class="h-6 w-6 shrink-0" strokeWidth={2} />
<span class="text-base">Reproducir a continuación</span>
</button>
{#if playlists.length > 0}
<div class="group/addmenu relative">
<div
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm text-fg-2 transition group-hover/addmenu:bg-hover"
>
<span class="flex-1 cursor-default text-base">Añadir a una playlist</span>
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
</div>
<div
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 {menu.openLeft
? 'right-full pr-1.5'
: 'left-full pl-1.5'}"
>
<div
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 shadow-menu"
>
<div class="max-h-80 overflow-y-auto">
{#each playlists as playlist (playlist.id)}
<form
method="POST"
action={menu.kind === 'youtube' ? '?/addYouTubeToPlaylist' : '?/addTrack'}
use:enhance={() =>
({ update }) => {
onClose();
return update({ reset: false, invalidateAll: false });
}}
>
<input type="hidden" name="playlistId" value={playlist.id} />
{#if menu.kind === 'youtube'}
<input type="hidden" name="videoId" value={menu.song.videoId} />
<input type="hidden" name="title" value={menu.song.title} />
<input type="hidden" name="artist" value={menu.song.artist} />
<input type="hidden" name="durationSeconds" value={menu.song.durationSeconds} />
<input type="hidden" name="thumbnailUrl" value={menu.song.thumbnailUrl} />
{:else}
<input type="hidden" name="trackId" value={menu.track.id} />
{/if}
<button
type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg transition hover:bg-hover"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={[]}
hue={hueFor(playlist.id)}
size="small"
class="h-8 w-8 flex-shrink-0 rounded-control"
/>
<span class="truncate text-base text-fg-2">{playlist.name}</span>
</button>
</form>
{/each}
</div>
</div>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import PlayPauseOverlay from '$lib/components/ui/PlayPauseOverlay.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
interface Props {
id: string | number;
title: string;
artist?: string;
coverSrc?: string;
coverSize?: 'small' | 'medium' | 'large';
hue: number;
explicit?: boolean;
active: boolean;
playing: boolean;
index: number;
onClick: () => void;
onContextMenu?: (event: MouseEvent) => void;
badge?: Snippet;
}
let {
id,
title,
artist,
coverSrc,
coverSize = 'large',
hue,
explicit = false,
active,
playing,
index,
onClick,
onContextMenu,
badge
}: Props = $props();
</script>
<li
class="group animate-enter relative min-w-0"
style="animation-delay:{index * 40}ms"
oncontextmenu={onContextMenu}
>
<button
type="button"
onclick={onClick}
aria-label="{active && playing ? 'Pausar' : 'Reproducir'} {title}"
class="block w-full text-left"
>
<Cover
trackId={id}
src={coverSrc}
{hue}
size={coverSize}
alt={title}
class="aspect-square w-full rounded-art shadow-art"
>
<PlayPauseOverlay {active} {playing} />
</Cover>
<div class="mt-2.5 flex min-w-0 items-center gap-1.5">
{#if explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-fg" title={title}>{title}</span>
</div>
{#if badge || artist}
<div class="mt-0.5 flex min-w-0 items-center gap-1.5">
{@render badge?.()}
{#if artist}
<span class="truncate text-sm text-fg-3" title={artist}>{artist}</span>
{/if}
</div>
{/if}
</button>
</li>
@@ -6,7 +6,8 @@
</script>
<div
class="animate-enter fixed bottom-0 left-[264px] right-0 z-50 h-[92px] border-t border-[var(--mf-border)] bg-[var(--mf-bg)]"
class="animate-enter fixed bottom-0 right-0 z-50 h-[92px] border-t border-line bg-bg"
style="left:var(--mf-sidebar-w)"
>
<div
class="grid h-full items-center gap-5 px-8"
@@ -8,18 +8,18 @@
}
</script>
<div class="flex w-full max-w-[900px] items-center gap-[9px]">
<span class="w-8 text-right text-[11px] tabular-nums text-[var(--mf-text-4)]">
<div class="flex w-full max-w-172 items-center gap-3">
<span class="text-right text-xs tabular-nums text-muted">
{fmtTime(player.progress)}
</span>
<button
type="button"
onclick={seek}
aria-label="Barra de progreso"
class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-[var(--mf-track)] transition-[height] hover:h-2"
class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-track transition-[height] hover:h-2"
>
<div
class="absolute inset-y-0 left-0 rounded-full bg-[var(--mf-accent)] group-hover:brightness-110"
class="absolute inset-y-0 left-0 rounded-full bg-accent/60 group-hover:brightness-110"
style="width:{player.progressPercent}%"
></div>
<span
@@ -27,7 +27,7 @@
style="left:{player.progressPercent}%"
></span>
</button>
<span class="w-8 text-[11px] tabular-nums text-[var(--mf-text-4)]">
<span class="text-xs tabular-nums text-(--mf-text-4)">
{fmtTime(player.current.duration)}
</span>
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
</script>
<div class="flex min-w-0 items-center gap-3">
@@ -11,18 +12,21 @@
hue={player.current.hue}
size="small"
alt={player.current.title}
class="h-[52px] w-[52px] flex-shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
class="h-13 w-13 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
/>
{:else}
<div
class="h-[52px] w-[52px] flex-shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
class="h-13 w-13 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
style="background:{player.artGradient}"
></div>
{/if}
<div class="min-w-0">
<div class="truncate text-[13.5px] font-semibold text-[var(--mf-text)]">
{player.current.title}
<div class="flex min-w-0 items-center gap-1.5">
{#if player.current.explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-fg">{player.current.title}</span>
</div>
<div class="truncate text-xs text-[var(--mf-text-3)]">{player.current.artist || '—'}</div>
<div class="truncate text-sm text-muted">{player.current.artist || '—'}</div>
</div>
</div>
@@ -7,7 +7,7 @@
type="button"
onclick={() => player.previous()}
aria-label="Anterior"
class="flex text-[var(--mf-text)] opacity-70 transition hover:opacity-100 active:scale-90"
class="flex text-fg opacity-70 transition hover:opacity-100 active:scale-90"
>
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
<rect x="0" y="0" width="2.5" height="14" rx="1" />
@@ -19,7 +19,7 @@
type="button"
onclick={() => player.toggle()}
aria-label={player.playing ? 'Pausar' : 'Reproducir'}
class="grid h-[36px] w-[36px] place-items-center rounded-full bg-[var(--mf-text)] text-[var(--mf-bg)] shadow-lg transition duration-100 active:scale-95"
class="grid h-[36px] w-[36px] place-items-center rounded-full bg-fg text-bg shadow-lg transition duration-100 active:scale-95"
>
{#if player.playing}
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
@@ -37,7 +37,7 @@
type="button"
onclick={() => player.next()}
aria-label="Siguiente"
class="flex text-[var(--mf-text)] opacity-70 transition hover:opacity-100 active:scale-90"
class="flex text-fg opacity-70 transition hover:opacity-100 active:scale-90"
>
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
<path d="M1 0 11 7 1 14z" />
@@ -17,7 +17,7 @@
oninput={handleVolumeChange}
aria-label="Volumen"
class="mf-volume"
style="background:linear-gradient(to right, var(--mf-accent) {player.volume}%, var(--mf-track) {player.volume}%)"
style="background:linear-gradient(to right, var(--mf-accent-60) {player.volume}%, var(--mf-track) {player.volume}%)"
/>
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
tone?: 'neutral' | 'danger';
class?: string;
children: Snippet;
}
let { tone = 'neutral', class: klass = '', children }: Props = $props();
const tones = {
neutral: 'border-line bg-surface text-fg-2',
danger: 'border-danger/40 bg-danger/10 text-danger-fg'
};
</script>
<p class="rounded-control border px-4 py-3 text-base {tones[tone]} {klass}">
{@render children()}
</p>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
tone?: 'neutral' | 'accent' | 'danger';
class?: string;
children: Snippet;
}
let { tone = 'neutral', class: klass = '', children }: Props = $props();
const tones = {
neutral: 'bg-surface-2 text-fg',
accent: 'bg-accent/20 text-accent',
danger: 'bg-danger/10 text-danger-fg'
};
</script>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-sm leading-none font-semibold {tones[tone]} {klass}"
>
{@render children()}
</span>
@@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
interface Props {
variant?: 'primary' | 'secondary' | 'subtle' | 'danger';
size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit';
href?: string;
reload?: boolean;
disabled?: boolean;
loading?: boolean;
onclick?: (event: MouseEvent) => void;
class?: string;
children: Snippet;
}
let {
variant = 'primary',
size = 'md',
type = 'button',
href,
reload = false,
disabled = false,
loading = false,
onclick,
class: klass = '',
children
}: Props = $props();
const sizes = {
sm: 'px-4 py-2 text-base',
md: 'px-5 py-2.5 text-base',
lg: 'px-8 py-3.5 text-base'
};
const variants = {
primary: 'bg-accent-soft font-semibold text-on-accent active:scale-[0.97]',
secondary: 'border border-line text-fg hover:bg-hover',
subtle: 'bg-surface-2 font-semibold text-fg hover:bg-surface-hover',
danger: 'bg-danger font-semibold text-white hover:brightness-110'
};
const classes = $derived(
`inline-flex items-center justify-center gap-2 rounded-control transition disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
);
</script>
{#if href}
<a {href} data-sveltekit-reload={reload ? '' : undefined} class={classes}>
{@render children()}
</a>
{:else}
<button {type} disabled={disabled || loading} {onclick} class={classes}>
{#if loading}
<LoaderCircle class="h-4 w-4 animate-spin" />
{/if}
{@render children()}
</button>
{/if}
@@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Check from '@lucide/svelte/icons/check';
interface Props {
checked?: boolean;
name?: string;
class?: string;
children: Snippet;
}
let { checked = $bindable(false), name, class: klass = '', children }: Props = $props();
</script>
<label class="flex items-center gap-3 text-base text-fg-2 {klass}">
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
<input
type="checkbox"
{name}
bind:checked
class="peer h-5 w-5 cursor-pointer appearance-none rounded border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
/>
<Check
class="pointer-events-none absolute h-3.5 w-3.5 text-on-accent opacity-0 transition peer-checked:opacity-100"
strokeWidth={3}
/>
</span>
<span class="cursor-pointer">{@render children()}</span>
</label>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { LucideIcon } from '@lucide/svelte';
interface Props {
icon?: LucideIcon;
title?: string;
description?: string;
class?: string;
actions?: Snippet;
}
let { icon: Icon, title, description, class: klass = '', actions }: Props = $props();
</script>
<div class="flex flex-col items-center px-6 py-14 text-center {klass}">
{#if Icon}
<span class="grid h-14 w-14 place-items-center rounded-full bg-accent-soft text-on-accent">
<Icon class="h-6 w-6" />
</span>
{/if}
{#if title}
<h3 class="mt-5 text-2xl font-bold tracking-tight text-fg">{title}</h3>
{/if}
{#if description}
<p class="mt-2 text-fg-2">{description}</p>
{/if}
{#if actions}
<div class="mt-6 flex items-center gap-3">{@render actions()}</div>
{/if}
</div>
@@ -0,0 +1,13 @@
<script lang="ts">
interface Props {
class?: string;
}
let { class: klass = '' }: Props = $props();
</script>
<span
class="grid h-4 w-4 shrink-0 place-items-center rounded-sm bg-fg-3 text-[11px] leading-4 font-bold text-bg {klass}"
title="Contenido explícito"
aria-label="Contenido explícito"
>E</span>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
label: string;
for?: string;
optional?: boolean;
class?: string;
hint?: Snippet;
children: Snippet;
}
let { label, for: forId, optional = false, class: klass = '', hint, children }: Props = $props();
</script>
<div class="flex min-w-0 flex-col {klass}">
<label for={forId} class="mb-2 block text-base font-medium text-fg-2">
{label}
{#if optional}<span class="text-muted">(Opcional)</span>{/if}
</label>
{@render children()}
{#if hint}
<div class="mt-2 flex items-center justify-between gap-3 text-sm text-muted">
{@render hint()}
</div>
{/if}
</div>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
label: string;
type?: 'button' | 'submit';
size?: 'sm' | 'md';
revealOnHover?: boolean;
onclick?: (event: MouseEvent) => void;
class?: string;
children: Snippet;
}
let {
label,
type = 'button',
size = 'md',
revealOnHover = false,
onclick,
class: klass = '',
children
}: Props = $props();
const sizes = { sm: 'h-7 w-7', md: 'h-8 w-8' };
</script>
<button
{type}
{onclick}
aria-label={label}
title={label}
class="grid place-items-center rounded-control text-muted transition hover:bg-hover hover:text-fg {sizes[
size
]} {revealOnHover ? 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100' : ''} {klass}"
>
{@render children()}
</button>
@@ -0,0 +1,47 @@
<script lang="ts">
import ImageIcon from '@lucide/svelte/icons/image';
interface Props {
name: string;
fallbackUrl?: string;
class?: string;
}
let {
name,
fallbackUrl,
class: klass = 'h-52 w-52 rounded-control border border-line bg-surface hover:border-accent/50'
}: Props = $props();
let preview = $state('');
let fallbackFailed = $state(false);
function onInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) preview = URL.createObjectURL(file);
}
</script>
<label class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {klass}">
<input
type="file"
{name}
accept="image/*"
onchange={onInput}
class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar portada"
/>
{#if preview !== ''}
<img src={preview} alt="Portada" class="h-full w-full object-cover" />
{:else if fallbackUrl && !fallbackFailed}
<img
src={fallbackUrl}
alt="Portada actual"
loading="lazy"
onerror={() => (fallbackFailed = true)}
class="h-full w-full object-cover"
/>
{:else}
<ImageIcon class="h-12 w-12 text-muted" />
{/if}
</label>
@@ -0,0 +1,38 @@
<script lang="ts">
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
interface Props {
onLoadMore: () => void;
hasMore: boolean;
loading: boolean;
rootMargin?: string;
}
let { onLoadMore, hasMore, loading, rootMargin = '600px' }: Props = $props();
let sentinel = $state<HTMLDivElement>();
let visible = $state(false);
$effect(() => {
const node = sentinel;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
visible = entries[0].isIntersecting;
},
{ rootMargin }
);
observer.observe(node);
return () => observer.disconnect();
});
$effect(() => {
if (visible && hasMore && !loading) onLoadMore();
});
</script>
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
{#if loading}
<LoaderCircle class="h-5 w-5 animate-spin text-muted" />
{/if}
</div>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { LucideIcon } from '@lucide/svelte';
interface Props {
value?: string;
id?: string;
name?: string;
type?: 'text' | 'search';
placeholder?: string;
maxlength?: number;
required?: boolean;
autocomplete?: 'on' | 'off';
icon?: LucideIcon;
oninput?: (event: Event) => void;
class?: string;
}
let {
value = $bindable(''),
id,
name,
type = 'text',
placeholder,
maxlength,
required = false,
autocomplete = 'off',
icon: Icon,
oninput,
class: klass = ''
}: Props = $props();
</script>
<div class="relative w-full">
{#if Icon}
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted">
<Icon class="h-4 w-4" />
</span>
{/if}
<input
{id}
{name}
{type}
{placeholder}
{maxlength}
{required}
{autocomplete}
{oninput}
bind:value
class="w-full rounded-control border border-line bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {Icon
? 'pr-4 pl-11'
: 'px-4'} {klass}"
/>
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
as?: 'ul' | 'div';
min?: string;
class?: string;
children: Snippet;
}
let { as = 'div', min = '180px', class: klass = '', children }: Props = $props();
</script>
<svelte:element
this={as}
class="grid gap-4 {klass}"
style="grid-template-columns:repeat(auto-fill,minmax({min},1fr))"
>
{@render children()}
</svelte:element>
@@ -0,0 +1,94 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import X from '@lucide/svelte/icons/x';
import IconButton from './IconButton.svelte';
interface Props {
open: boolean;
onClose: () => void;
title?: string;
maxWidth?: string;
panelClass?: string;
children: Snippet;
}
let {
open,
onClose,
title,
maxWidth = 'max-w-2xl',
panelClass = 'border border-line bg-elevated',
children
}: Props = $props();
let panel: HTMLDivElement | undefined = $state();
$effect(() => {
if (!open) return;
const trigger = document.activeElement as HTMLElement | null;
panel?.focus();
return () => trigger?.focus();
});
function onBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) onClose();
}
function focusableElements(): HTMLElement[] {
if (!panel) return [];
return Array.from(
panel.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
);
}
function onWindowKeydown(event: KeyboardEvent) {
if (!open) return;
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key !== 'Tab') return;
const elements = focusableElements();
if (elements.length === 0) return;
const first = elements[0];
const last = elements[elements.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
{#if open}
<div
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
role="presentation"
onclick={onBackdropClick}
>
<div
bind:this={panel}
class="animate-pop w-full {maxWidth} rounded-panel p-6 shadow-2xl outline-none {panelClass}"
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
>
{#if title}
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold tracking-tight text-fg">{title}</h2>
<IconButton label="Cerrar" onclick={onClose}>
<X class="h-4 w-4" />
</IconButton>
</div>
{/if}
{@render children()}
</div>
</div>
{/if}
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children: Snippet;
}
let { class: klass = '', children }: Props = $props();
</script>
<section class="page-x pt-11 pb-10 {klass}">
{@render children()}
</section>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
subtitle?: string;
class?: string;
actions?: Snippet;
}
let { title, subtitle, class: klass = '', actions }: Props = $props();
</script>
<div class="flex items-end justify-between gap-4 {klass}">
<div class="min-w-0">
<h1 class="text-3xl font-bold tracking-tight text-fg sm:text-4xl">{title}</h1>
{#if subtitle}
<p class="mt-2 text-fg-2">{subtitle}</p>
{/if}
</div>
{#if actions}
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
{/if}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
interface Props {
active: boolean;
playing: boolean;
}
let { active, playing }: Props = $props();
</script>
<span
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-accent-soft text-on-accent opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
class:!opacity-100={active}
class:!translate-y-0={active}
>
{#if active && playing}
<Pause class="h-5 w-5" fill="currentColor" />
{:else}
<Play class="h-5 w-5" fill="currentColor" />
{/if}
</span>
@@ -1,23 +1,17 @@
<script lang="ts">
import Cover from './Cover.svelte';
import { gradientForHue, HUES } from '$lib/theme/color';
import { gradientForHue, hueFor } from '$lib/theme/color';
interface Props {
playlistId?: string;
trackIds: (string | number)[];
hue: number;
size?: 'small' | 'medium' | 'large';
version?: string;
class?: string;
}
let { playlistId, trackIds, hue, size = 'medium', class: klass = '' }: Props = $props();
function hueFor(id: string | number) {
const text = String(id);
let hash = 0;
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
let { playlistId, trackIds, hue, size = 'medium', version, class: klass = '' }: Props = $props();
let coverFailed = $state(false);
@@ -28,7 +22,7 @@
{#if playlistId && !coverFailed}
<div class="relative overflow-hidden {klass}" style="background:{gradientForHue(hue)}">
<img
src="/api/playlists/{playlistId}/cover?size={size}"
src="/api/playlists/{playlistId}/cover?size={size}{version ? `&v=${encodeURIComponent(version)}` : ''}"
alt=""
loading="lazy"
onerror={() => (coverFailed = true)}
@@ -0,0 +1,33 @@
<script lang="ts">
import PlaylistArt from './PlaylistArt.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
id: string;
name: string;
description?: string;
trackIds: (string | number)[];
updatedAt: string;
index?: number;
}
let { id, name, description, trackIds, updatedAt, index = 0 }: Props = $props();
</script>
<a
href="/playlists/{id}"
class="group animate-enter min-w-0 text-left"
style="animation-delay:{index * 45}ms"
>
<PlaylistArt
playlistId={id}
{trackIds}
hue={hueFor(id)}
version={updatedAt}
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
/>
<div class="mt-2.5 truncate text-fg">{name}</div>
{#if description}
<div class="mt-0.5 truncate text-sm text-fg-3">{description}</div>
{/if}
</a>
@@ -0,0 +1,95 @@
<script lang="ts">
import { enhance } from '$app/forms';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte';
import Input from './Input.svelte';
import Textarea from './Textarea.svelte';
import Alert from './Alert.svelte';
interface Props {
action: string;
initialName?: string;
initialDescription?: string;
namePlaceholder?: string;
coverFallbackUrl?: string;
formMessage?: string;
submitLabel: string;
submittingLabel: string;
onCancel: () => void;
onSuccess?: () => void;
}
let {
action,
initialName = '',
initialDescription = '',
namePlaceholder = '',
coverFallbackUrl,
formMessage,
submitLabel,
submittingLabel,
onCancel,
onSuccess
}: Props = $props();
let name = $state(initialName);
let description = $state(initialDescription);
let submitting = $state(false);
</script>
<form
method="POST"
{action}
enctype="multipart/form-data"
use:enhance={() => {
submitting = true;
return async ({ update, result }) => {
await update({ reset: false });
submitting = false;
if (result.type === 'success') onSuccess?.();
};
}}
class="mt-4 w-full max-w-4xl space-y-4"
>
<div class="flex items-stretch gap-8">
<Field label="Portada" optional>
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} />
</Field>
<div class="flex flex-1 flex-col gap-4">
<Field label="Nombre" for="playlist-name">
<Input
id="playlist-name"
name="name"
maxlength={100}
required
bind:value={name}
placeholder={namePlaceholder}
/>
</Field>
<Field label="Descripción" for="playlist-description" optional class="min-h-0 flex-1">
<Textarea
id="playlist-description"
name="description"
maxlength={300}
bind:value={description}
placeholder="¿De qué va esta lista?"
class="flex-1"
/>
</Field>
</div>
</div>
{#if formMessage}
<Alert tone="danger">{formMessage}</Alert>
{/if}
<div class="flex items-center justify-end gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || name.trim() === ''}>
{submitting ? submittingLabel : submitLabel}
</Button>
</div>
</form>
@@ -0,0 +1,10 @@
<script lang="ts">
interface Props {
title: string;
class?: string;
}
let { title, class: klass = '' }: Props = $props();
</script>
<h2 class="mb-6 text-2xl font-bold text-fg {klass}">{title}</h2>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
padding?: 'sm' | 'md' | 'lg';
class?: string;
children: Snippet;
}
let { padding = 'md', class: klass = '', children }: Props = $props();
const paddings = { sm: 'p-4', md: 'p-6', lg: 'p-12' };
</script>
<div class="rounded-panel border border-line bg-surface transition {paddings[padding]} {klass}">
{@render children()}
</div>
@@ -0,0 +1,28 @@
<script lang="ts">
interface Props {
value?: string;
id?: string;
name?: string;
placeholder?: string;
maxlength?: number;
class?: string;
}
let {
value = $bindable(''),
id,
name,
placeholder,
maxlength,
class: klass = ''
}: Props = $props();
</script>
<textarea
{id}
{name}
{placeholder}
{maxlength}
bind:value
class="w-full min-h-0 resize-none rounded-control border border-line bg-surface px-4 py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {klass}"
></textarea>
@@ -0,0 +1,27 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import NowPlaying from './NowPlaying.svelte';
interface Props {
index: number;
active: boolean;
playing: boolean;
onToggle: () => void;
}
let { index, active, playing, onToggle }: Props = $props();
</script>
<button
type="button"
onclick={onToggle}
aria-label={active && playing ? 'Pausar' : 'Reproducir'}
class="relative grid h-8 w-8 place-items-center overflow-hidden rounded text-sm text-muted"
>
{#if active}
<NowPlaying paused={!playing} />
{:else}
<span class="group-hover:hidden">{index + 1}</span>
<Play class="hidden h-3.5 w-3.5 text-fg group-hover:block" fill="currentColor" />
{/if}
</button>
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children: Snippet;
}
let { class: klass = '', children }: Props = $props();
</script>
<span class="truncate text-center text-sm tabular-nums text-muted {klass}">
{@render children()}
</span>
@@ -0,0 +1,24 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Snippet } from 'svelte';
import { trackTableKey, type TrackTableContext } from './TrackTable.svelte';
interface Props {
class?: string;
oncontextmenu?: (event: MouseEvent) => void;
children: Snippet;
}
let { class: klass = '', oncontextmenu, children }: Props = $props();
const table = getContext<TrackTableContext>(trackTableKey);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group grid items-center gap-8 rounded-control px-3 py-2.5 transition hover:bg-surface {klass}"
style="grid-template-columns:{table.columns}"
{oncontextmenu}
>
{@render children()}
</div>
@@ -0,0 +1,39 @@
<script lang="ts" module>
export const trackTableKey = Symbol('track-table');
export interface TrackTableContext {
readonly columns: string;
}
</script>
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
columns: string;
class?: string;
headers: Snippet;
children: Snippet;
}
let { columns, class: klass = '', headers, children }: Props = $props();
setContext<TrackTableContext>(trackTableKey, {
get columns() {
return columns;
}
});
</script>
<div class={klass}>
<div
class="grid items-center gap-8 border-b border-line px-3 pb-2 text-sm font-medium tracking-wide text-muted uppercase"
style="grid-template-columns:{columns}"
>
{@render headers()}
</div>
<div class="mt-1 flex flex-col">
{@render children()}
</div>
</div>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Cover from './Cover.svelte';
import ExplicitBadge from './ExplicitBadge.svelte';
interface Props {
trackId: string | number;
title: string;
artist?: string;
coverSrc?: string;
coverSize?: string;
hue: number;
explicit?: boolean;
titleClass?: string;
onClick: () => void;
overlay?: Snippet;
badge?: Snippet;
}
let {
trackId,
title,
artist,
coverSrc,
coverSize = 'h-[42px] w-[42px]',
hue,
explicit = false,
titleClass = '',
onClick,
overlay,
badge
}: Props = $props();
</script>
<button type="button" onclick={onClick} class="flex min-w-0 items-center gap-3.5 text-left">
<Cover
trackId={trackId}
src={coverSrc}
{hue}
size="small"
alt={title}
class="{coverSize} flex-shrink-0 rounded-control"
>
{@render overlay?.()}
</Cover>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
{#if explicit}
<ExplicitBadge />
{/if}
<span class="min-w-0 truncate text-fg {titleClass}">{title}</span>
{@render badge?.()}
</div>
{#if artist}
<div class="truncate text-left text-sm text-fg-3">{artist}</div>
{/if}
</div>
</button>
+3
View File
@@ -0,0 +1,3 @@
export const EXPLORE_PAGE_SIZE = 27;
export const HOME_LATEST_PAGE_SIZE = 12;
export const YOUTUBE_FILLER_LIMIT = 27;
+15 -11
View File
@@ -1,5 +1,5 @@
import { browser } from '$app/environment';
import { HUES, accentForHue, gradientForHue } from '$lib/theme/color';
import { HUES, accentForHue, gradientForHue, hueFor } from '$lib/theme/color';
import { extractAccent, type Accent } from '$lib/theme/palette';
export type TrackSourceKind = 'local' | 'youtube';
@@ -12,6 +12,7 @@ export interface PlayerTrack {
hue: number;
source: TrackSourceKind;
coverUrl?: string;
explicit?: boolean;
}
export interface Playlist {
@@ -26,6 +27,7 @@ export interface QueueItem {
artist?: string;
source?: TrackSourceKind;
coverUrl?: string;
explicit?: boolean;
}
export interface ApiTrackLike {
@@ -35,6 +37,7 @@ export interface ApiTrackLike {
source?: 'Local' | 'YouTube';
externalId?: string;
audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed';
isExplicit?: boolean;
}
export function isYouTubeTrack(track: ApiTrackLike): boolean {
@@ -65,7 +68,8 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
? isPendingYouTubeTrack(track) && track.externalId
? youTubeThumbnailUrl(track.externalId)
: `/api/tracks/${track.id}/cover?size=small`
: undefined
: undefined,
explicit: track.isExplicit
};
});
}
@@ -74,13 +78,6 @@ const RECENT_LIMIT = 15;
const EMPTY: PlayerTrack = { id: '', title: '', artist: '', duration: 0, hue: HUES[0], source: 'local' };
function hueFor(id: string | number): number {
const text = String(id);
let hash = 0;
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
function toTrack(item: QueueItem): PlayerTrack {
return {
id: item.id,
@@ -88,6 +85,7 @@ function toTrack(item: QueueItem): PlayerTrack {
artist: item.artist ?? '',
duration: 0,
hue: hueFor(item.id),
explicit: item.explicit,
source: item.source ?? 'local',
coverUrl: item.coverUrl
};
@@ -99,7 +97,7 @@ class PlayerState {
playing = $state(false);
progress = $state(0);
volume = $state(
browser ? Number(localStorage.getItem("player.volume") ?? 100) : 100
browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100
);
loading = $state(false);
error = $state('');
@@ -333,6 +331,12 @@ class PlayerState {
this.#loadCurrent();
}
playOrToggle(list: QueueItem[], index: number) {
if (index < 0 || index >= list.length) return;
if (this.currentId === list[index].id) this.toggle();
else this.playQueue(list, index);
}
addToQueue(item: QueueItem) {
if (this.currentId === null) {
this.playQueue([item], 0);
@@ -404,7 +408,7 @@ class PlayerState {
const audio = this.#audioEl();
if (audio) {
audio.volume = this.volume / 100;
localStorage.setItem("player.volume", String(this.volume));
localStorage.setItem('player.volume', String(this.volume));
}
}
}
+46
View File
@@ -1,4 +1,5 @@
import createClient from 'openapi-fetch';
import { error, fail, type ActionFailure } from '@sveltejs/kit';
import type { paths } from '$lib/api/schema';
import { apiConfig } from '$lib/server/config';
@@ -14,3 +15,48 @@ export function createApiClient({ fetch, accessToken }: ApiClientOptions) {
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined
});
}
export function requireAccessToken(locals: App.Locals, message = 'Inicia sesión.'): string {
if (!locals.accessToken) error(401, message);
return locals.accessToken;
}
export function requireAccessTokenAction(
locals: App.Locals,
message = 'Inicia sesión.'
): string | ActionFailure<{ message: string }> {
return locals.accessToken ?? fail(401, { message });
}
export function unwrapOrError<T>(
result: { data?: T; error?: unknown; response?: Response },
message: string,
status?: number
): T {
if (result.error || result.data === undefined) {
error(status ?? result.response?.status ?? 502, message);
}
return result.data;
}
export function unwrapOrFail(
result: { error?: unknown },
message: string
): ActionFailure<{ message: string }> | undefined {
return result.error ? fail(502, { message }) : undefined;
}
export async function playlistCoverTrackIds(
api: ReturnType<typeof createApiClient>,
playlistIds: string[]
): Promise<Record<string, string[]>> {
const covers = await Promise.all(
playlistIds.map(async (playlistId) => {
const { data: tracks } = await api.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId }, query: { pageNumber: 1, pageSize: 4 } }
});
return [playlistId, tracks?.items.map((t) => t.id) ?? []] as const;
})
);
return Object.fromEntries(covers);
}
+26
View File
@@ -0,0 +1,26 @@
import { apiConfig } from '$lib/server/config';
const ALLOWED_SIZES = new Set(['small', 'medium', 'large']);
export async function proxyCoverImage(
fetch: typeof globalThis.fetch,
kind: 'playlists' | 'tracks',
id: string,
size: string | null
): Promise<Response> {
const safeSize = size && ALLOWED_SIZES.has(size) ? size : 'medium';
const upstream = await fetch(`${apiConfig.baseUrl}/${kind}/${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'
}
});
}
@@ -0,0 +1,66 @@
import { fail, type RequestEvent } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const trackId = String(form.get('trackId') ?? '');
if (!playlistId || !trackId) {
return fail(400, { message: 'Faltan datos de la canción.' });
}
const api = createApiClient({ fetch, accessToken });
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', {
params: { path: { playlistId, trackId } }
});
if (err) {
if (response?.status === 409) {
return fail(409, { message: 'La canción ya está en esa playlist.', trackId });
}
return fail(502, { message: 'No se pudo añadir la canción.', trackId });
}
return { added: true, trackId, playlistId };
}
export async function addYouTubeToPlaylistAction({
request,
params,
locals,
fetch
}: RequestEvent) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const videoId = String(form.get('videoId') ?? '');
const title = String(form.get('title') ?? '');
const artist = String(form.get('artist') ?? '');
const durationSeconds = Number(form.get('durationSeconds') ?? 0);
const thumbnailUrl = String(form.get('thumbnailUrl') ?? '');
if (!playlistId || !videoId || !title) {
return fail(400, { message: 'Faltan datos de la canción.' });
}
const api = createApiClient({ fetch, accessToken });
const { error: err, response } = await api.POST('/playlists/{playlistId}/youtube-tracks', {
params: { path: { playlistId } },
body: { videoId, title, artist, durationSeconds, thumbnailUrl }
});
if (err) {
if (response?.status === 409) {
return fail(409, { message: 'La canción ya está en esa playlist.', videoId });
}
return fail(502, { message: 'No se pudo añadir la canción.', videoId });
}
return { added: true, videoId, playlistId };
}
+63
View File
@@ -4,6 +4,33 @@ import { env } from '$env/dynamic/private';
const CONNECT_HOST = env.S3_UPLOAD_CONNECT_HOST || '127.0.0.1';
export const IMAGE_TYPES: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp'
};
export 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'
};
export function extOf(name: string): string {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
}
export function contentTypeOf(file: File, table: Record<string, string> = IMAGE_TYPES): string {
if (file.type) return file.type;
return table[extOf(file.name)] ?? 'application/octet-stream';
}
export async function putPresigned(
presignedUrl: string,
body: Buffer,
@@ -49,3 +76,39 @@ export async function putPresigned(
request.end();
});
}
export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string };
export async function uploadPresignedImage(
reserve: (args: {
fileType: string;
contentType: string;
expectedSizeBytes: number;
}) => Promise<{
data?: { uploadUrl: string; contentType: string; intentId: string } | null;
error?: unknown;
}>,
file: File
): Promise<UploadImageResult> {
const { data: reserved, error: reserveError } = await reserve({
fileType: extOf(file.name) || 'jpg',
contentType: contentTypeOf(file),
expectedSizeBytes: file.size
});
if (reserveError || !reserved) {
return { failMessage: 'No se pudo reservar la subida de la portada.' };
}
try {
const buffer = await file.arrayBuffer();
await putPresigned(reserved.uploadUrl, Buffer.from(buffer), reserved.contentType);
} catch (err) {
return {
failMessage: 'Falló la subida de la portada al almacenamiento.',
detail: err instanceof Error ? err.message : undefined
};
}
return { intentId: reserved.intentId };
}
+54
View File
@@ -0,0 +1,54 @@
import type { createApiClient } from '$lib/server/api';
import type { YouTubeSong } from '$lib/types';
export const YOUTUBE_FILLER_QUERIES = [
'pop',
'rock',
'reggaeton',
'lo-fi',
'indie',
'salsa',
'k-pop',
'electrónica',
'jazz',
'baladas',
'trap',
'cumbia',
'hip hop',
'música clásica'
];
export interface YoutubeFiller {
query: string;
items: YouTubeSong[];
continuationToken: string;
}
export function pickRandomFillerQuery(): string {
return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)];
}
export function shuffle<T>(items: T[]): T[] {
const copy = [...items];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
export async function fetchYoutubeFiller(
api: ReturnType<typeof createApiClient>,
accessToken: string | null | undefined,
limit: number,
seed?: string
): Promise<YoutubeFiller> {
if (!accessToken) return { query: '', items: [], continuationToken: '' };
const query = seed?.trim() || pickRandomFillerQuery();
const { data } = await api.GET('/youtube/search', { params: { query: { query } } });
return {
query,
items: ((data?.items ?? []) as YouTubeSong[]).slice(0, limit),
continuationToken: data?.continuationToken ?? ''
};
}
+18 -1
View File
@@ -1,7 +1,18 @@
export const HUES = [145, 330, 40, 152, 300, 200, 10, 120, 280];
export const ACCENT_LIGHTNESS = 60;
export const ACCENT_CHROMA = 0.17;
export const ACCENT_HUE = 145;
export function hueFor(id: string | number): number {
const text = String(id);
let hash = 0;
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
export function accentForHue(hue: number): string {
return `oklch(72% 0.17 ${hue})`;
return `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${hue})`;
}
export function accentSoft(hue: number, pct: number): string {
@@ -18,3 +29,9 @@ export function fmtTime(seconds: number): string {
const rest = total % 60;
return `${minutes}:${rest < 10 ? '0' : ''}${rest}`;
}
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
export function fmtDate(value: string | number | Date): string {
return dateFormatter.format(new Date(value));
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { browser } from '$app/environment';
import { ACCENT_LIGHTNESS } from './color';
export interface Accent {
accent: string;
@@ -107,7 +108,7 @@ export async function extractAccent(url: string): Promise<Accent | null> {
const h2 = ((hue + 25) % 360).toFixed(1);
return {
accent: `oklch(72% ${c} ${h})`,
accent: `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`,
gradient: `linear-gradient(135deg, oklch(74% ${c} ${h}), oklch(56% ${c2} ${h2}))`
};
}
+9 -9
View File
@@ -1,15 +1,13 @@
/* Paleta de Musify — cambia los colores aquí y afecta a toda la app.
El acento (--mf-accent) es dinámico: lo fija el player según la pista sonando. */
:root {
--mf-bg: #08080a;
--mf-bg: rgb(9, 9, 9);
--mf-elevated: #0b0b0d;
--mf-text: #f5f4f2;
--mf-text-2: rgba(245, 244, 242, 0.7);
--mf-text-3: rgba(245, 244, 242, 0.5);
--mf-text-4: rgba(245, 244, 242, 0.4);
--mf-text: #c7c6c5;
--mf-text-2: #a5a4a3;
--mf-text-3: #8c8b8a;
--mf-text-4: #7f7e7d;
--mf-surface: rgba(255, 255, 255, 0.045);
--mf-surface: rgb(18, 18, 18);
--mf-surface-2: rgba(255, 255, 255, 0.06);
--mf-surface-hover: rgba(255, 255, 255, 0.09);
@@ -17,6 +15,8 @@
--mf-border-strong: rgba(255, 255, 255, 0.12);
--mf-track: rgba(255, 255, 255, 0.12);
--mf-accent: oklch(72% 0.17 145);
--mf-accent: oklch(60% 0.17 145);
--mf-accent-60: color-mix(in oklch, var(--mf-accent) 80%, transparent);
--mf-radius: 12px;
--mf-sidebar-w: 264px;
}
+10
View File
@@ -4,3 +4,13 @@ export interface SessionUser {
email: string;
picture: string;
}
export interface YouTubeSong {
videoId: string;
title: string;
artist: string;
album: string;
durationSeconds: number;
thumbnailUrl: string;
isExplicit: boolean;
}