Changes
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
export const EXPLORE_PAGE_SIZE = 27;
|
||||
export const HOME_LATEST_PAGE_SIZE = 12;
|
||||
export const YOUTUBE_FILLER_LIMIT = 27;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 ?? ''
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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}))`
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user