refactor: rename frontend/ to web-player/ and format with prettier
Updates the compose build context, the Dockerfile header, the package name and the docs. The compose service and container keep the name frontend/musify-frontend, since nginx routes to http://frontend:3000. The source files also carry the first prettier pass, so the rename and the reformat land together for them.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import Home from '@lucide/svelte/icons/house';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import 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';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
let menuOpen = $state(false);
|
||||
let menuRef: HTMLDivElement | undefined = $state();
|
||||
|
||||
let navBox: HTMLElement | undefined = $state();
|
||||
let pillTop = $state(0);
|
||||
let pillHeight = $state(0);
|
||||
let pillVisible = $state(false);
|
||||
let animate = $state(false);
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
|
||||
}
|
||||
|
||||
const mainLinks = [
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
{ href: '/explore', label: 'Buscar', icon: Search },
|
||||
{ href: '/playlists', label: 'Listas', icon: Music }
|
||||
];
|
||||
|
||||
const secondaryLinks = [
|
||||
{ href: '/library', label: 'Canciones subidas', icon: Folder },
|
||||
{ href: '/upload', label: 'Subir música', icon: Upload }
|
||||
];
|
||||
|
||||
function isActive(href: string) {
|
||||
return href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void page.url.pathname;
|
||||
const box = navBox;
|
||||
if (!box) return;
|
||||
const active = box.querySelector<HTMLElement>('[data-active="true"]');
|
||||
if (!active) {
|
||||
pillVisible = false;
|
||||
return;
|
||||
}
|
||||
const boxRect = box.getBoundingClientRect();
|
||||
const rect = active.getBoundingClientRect();
|
||||
pillTop = rect.top - boxRect.top;
|
||||
pillHeight = rect.height;
|
||||
pillVisible = true;
|
||||
if (!animate) requestAnimationFrame(() => (animate = true));
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet navLink(link: { href: string; label: string; icon: typeof Home })}
|
||||
{@const active = isActive(link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
data-active={active}
|
||||
class="relative flex items-center gap-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-6 w-6 shrink-0" style={active ? 'color:var(--mf-accent-60)' : ''} />
|
||||
{link.label}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<aside
|
||||
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 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-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"
|
||||
style="background:var(--mf-accent-60)"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<nav class="relative z-10 flex flex-col gap-1">
|
||||
{#each mainLinks as link (link.href)}
|
||||
{@render navLink(link)}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="mx-2 border-t border-line"></div>
|
||||
|
||||
<nav class="relative z-10 flex flex-col gap-1">
|
||||
{#each secondaryLinks as link (link.href)}
|
||||
{@render navLink(link)}
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="relative mt-auto" bind:this={menuRef}>
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-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-base font-semibold text-fg-2 transition hover:bg-hover"
|
||||
>
|
||||
<LogOut class="h-[18px] w-[18px]" strokeWidth={2} />
|
||||
Salir
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
class="flex w-full items-center gap-2.5 rounded-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-surface-2 text-sm font-semibold uppercase"
|
||||
>
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="truncate text-base leading-[1.35] font-semibold">{user.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<svelte:window onclick={onDocumentClick} />
|
||||
@@ -0,0 +1,121 @@
|
||||
<script lang="ts" module>
|
||||
import type { TrackTarget } from '$lib/tracks';
|
||||
|
||||
export type ContextMenuState = TrackTarget & { x: number; y: number; openLeft: boolean };
|
||||
|
||||
const MENU_WIDTH = 208;
|
||||
const SUBMENU_WIDTH = 224;
|
||||
|
||||
export function contextMenuStateFor(event: MouseEvent, target: TrackTarget): 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}</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>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import TrackInfo from './TrackInfo.svelte';
|
||||
import TransportControls from './TransportControls.svelte';
|
||||
import SeekBar from './SeekBar.svelte';
|
||||
import VolumeControl from './VolumeControl.svelte';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="animate-enter fixed right-0 bottom-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"
|
||||
style="grid-template-columns:minmax(200px,1fr) minmax(0,3fr) minmax(200px,1fr)"
|
||||
>
|
||||
<TrackInfo />
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<TransportControls />
|
||||
<SeekBar />
|
||||
</div>
|
||||
<VolumeControl />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { fmtTime } from '$lib/theme/color';
|
||||
|
||||
function seek(event: MouseEvent) {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
player.seekFraction((event.clientX - rect.left) / rect.width);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex w-full max-w-172 items-center gap-3">
|
||||
<span class="text-right text-xs text-muted tabular-nums">
|
||||
{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-track transition-[height] hover:h-2"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 rounded-full bg-accent/60 group-hover:brightness-110"
|
||||
style="width:{player.progressPercent}%"
|
||||
></div>
|
||||
<span
|
||||
class="pointer-events-none absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 shadow-[0_1px_4px_rgba(0,0,0,0.5)] transition-opacity duration-75 group-hover:opacity-100"
|
||||
style="left:{player.progressPercent}%"
|
||||
></span>
|
||||
</button>
|
||||
<span class="text-xs text-(--mf-text-4) tabular-nums">
|
||||
{fmtTime(player.current.duration)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
<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">
|
||||
{#if player.current.id}
|
||||
<Cover
|
||||
trackId={player.current.id}
|
||||
src={player.current.coverUrl}
|
||||
hue={player.current.hue}
|
||||
size="small"
|
||||
alt={player.current.title}
|
||||
class="h-13 w-13 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
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="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-sm text-muted">{player.current.artist || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.previous()}
|
||||
aria-label="Anterior"
|
||||
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" />
|
||||
<path d="M15 0 5 7l10 7z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.toggle()}
|
||||
aria-label={player.playing ? 'Pausar' : 'Reproducir'}
|
||||
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">
|
||||
<rect x="1.5" y="0" width="3.5" height="12" rx="1" />
|
||||
<rect x="7" y="0" width="3.5" height="12" rx="1" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<path d="M2.5 0 12 6l-9.5 6z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.next()}
|
||||
aria-label="Siguiente"
|
||||
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" />
|
||||
<rect x="13.5" y="0" width="2.5" height="14" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
|
||||
function handleVolumeChange(event: Event) {
|
||||
const target = event.currentTarget as HTMLInputElement;
|
||||
const newVolume = Number(target.value);
|
||||
player.setVolume(newVolume);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-end gap-3.5">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={player.volume}
|
||||
oninput={handleVolumeChange}
|
||||
aria-label="Volumen"
|
||||
class="mf-volume"
|
||||
style="background:linear-gradient(to right, var(--mf-accent-60) {player.volume}%, var(--mf-track) {player.volume}%)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mf-volume {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
border-radius: 100px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mf-volume::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--mf-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mf-volume::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--mf-text);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -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,52 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { gradientForHue } from '$lib/theme/color';
|
||||
|
||||
interface Props {
|
||||
trackId: string | number;
|
||||
hue: number;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
src?: string;
|
||||
alt?: string;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
trackId,
|
||||
hue,
|
||||
size = 'medium',
|
||||
src,
|
||||
alt = '',
|
||||
class: klass = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
let failed = $state(false);
|
||||
let loaded = $state(false);
|
||||
|
||||
const imageSrc = $derived(src ?? `/api/tracks/${trackId}/cover?size=${size}`);
|
||||
|
||||
$effect(() => {
|
||||
imageSrc;
|
||||
failed = false;
|
||||
loaded = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative overflow-hidden {klass}"
|
||||
style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`}
|
||||
>
|
||||
{#if !failed}
|
||||
<img
|
||||
src={imageSrc}
|
||||
{alt}
|
||||
loading="lazy"
|
||||
onload={() => (loaded = true)}
|
||||
onerror={() => (failed = true)}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -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,49 @@
|
||||
<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,25 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
let { paused = false }: Props = $props();
|
||||
|
||||
const bars = [
|
||||
{ delay: 0, dur: 0.9 },
|
||||
{ delay: 0.25, dur: 0.7 },
|
||||
{ delay: 0.1, dur: 1.05 },
|
||||
{ delay: 0.4, dur: 0.8 }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="absolute inset-0 flex items-end justify-center gap-[2.5px] bg-black/45 pb-2">
|
||||
{#each bars as bar, i (i)}
|
||||
<span
|
||||
class="w-[3px] origin-bottom rounded-full bg-white"
|
||||
style="height:14px; animation:equalize {bar.dur}s ease-in-out {bar.delay}s infinite; animation-play-state:{paused
|
||||
? 'paused'
|
||||
: 'running'}"
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import Cover from './Cover.svelte';
|
||||
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', version, class: klass = '' }: Props = $props();
|
||||
|
||||
let coverFailed = $state(false);
|
||||
|
||||
const mosaic = $derived(trackIds.length >= 4);
|
||||
const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null);
|
||||
</script>
|
||||
|
||||
{#if playlistId && !coverFailed}
|
||||
<div class="relative overflow-hidden {klass}" style="background:{gradientForHue(hue)}">
|
||||
<img
|
||||
src="/api/playlists/{playlistId}/cover?size={size}{version
|
||||
? `&v=${encodeURIComponent(version)}`
|
||||
: ''}"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onerror={() => (coverFailed = true)}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{:else if mosaic}
|
||||
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
|
||||
{#each trackIds.slice(0, 4) as id (id)}
|
||||
<Cover trackId={id} hue={hueFor(id)} size="small" class="h-full w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if single !== null}
|
||||
<Cover trackId={single} hue={hueFor(single)} size="medium" class={klass} />
|
||||
{:else}
|
||||
<div class={klass} style="background:{gradientForHue(hue)}"></div>
|
||||
{/if}
|
||||
@@ -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,96 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
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(untrack(() => initialName));
|
||||
let description = $state(untrack(() => 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="min-h-0 w-full 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 text-muted tabular-nums {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}
|
||||
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>
|
||||
Reference in New Issue
Block a user