Add likes, follows and playlist visibility; redesign web-player UI
Backend: - New Likes feature (toggle/get liked tracks) and endpoints - User follow/unfollow, listening stats, public user profile - Playlist visibility (public/private) support - Various auth and DTO/response adjustments to support the above Frontend (web-player): - New design system pass: theme tokens/mode (light/dark), glass menu, tabs bar, player dock/extras/queue components, mix tiles, artist link - New routes: Liked, public user profile (/u/[id]), likes API proxy - Search refactor: shared SearchBox component used by TopBar and MobileHeader (search now reachable on mobile), live results as you type instead of requiring Enter, dropped the "Buscando…" quick-result preview - Removed now-unused components (MobileNav, CoverBadge, MixBentoTile, PlayPauseOverlay, SuggestionsTable, userDirectory) - Design handoff reference docs added under web-player/design_handoff_musify_player and web-player/docs/design-system-refactor-plan.md
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||
import ListEnd from '@lucide/svelte/icons/list-end';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import GlassMenu from '$lib/components/ui/GlassMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
menu: AlbumMenuState;
|
||||
@@ -55,32 +56,29 @@
|
||||
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;"
|
||||
>
|
||||
<GlassMenu class="fixed z-40 w-60" style="left:{menu.x}px; top:{menu.y}px;">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onPlayNext}
|
||||
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"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover"
|
||||
>
|
||||
<ListPlus class="h-5 w-5 shrink-0" strokeWidth={2} />
|
||||
<span class="text-base">Reproducir a continuación</span>
|
||||
<ListPlus class="h-[18px] w-[18px] shrink-0" strokeWidth={2} />
|
||||
<span>Reproducir a continuación</span>
|
||||
</button>
|
||||
<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"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover"
|
||||
>
|
||||
<ListEnd class="h-5 w-5 shrink-0" strokeWidth={2} />
|
||||
<span class="text-base">Añadir a la cola</span>
|
||||
<ListEnd class="h-[18px] w-[18px] shrink-0" strokeWidth={2} />
|
||||
<span>Añadir a la cola</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"
|
||||
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-[13.5px] font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
|
||||
>
|
||||
<span class="flex-1 cursor-default text-base">Añadir álbum a una playlist</span>
|
||||
<span class="flex-1 cursor-default">Añadir álbum a una playlist</span>
|
||||
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
|
||||
</div>
|
||||
<div
|
||||
@@ -108,7 +106,7 @@
|
||||
<input type="hidden" name="albumId" value={menu.albumId} />
|
||||
<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"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg transition hover:bg-hover"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
@@ -127,4 +125,4 @@
|
||||
{:else}
|
||||
<p class="px-2 py-2.5 text-sm text-fg-3">No tienes playlists todavía.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</GlassMenu>
|
||||
|
||||
@@ -4,13 +4,21 @@
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
import LogOut from '@lucide/svelte/icons/log-out';
|
||||
import LogIn from '@lucide/svelte/icons/log-in';
|
||||
import User from '@lucide/svelte/icons/user';
|
||||
import Settings from '@lucide/svelte/icons/settings';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import MenuItem from './ui/MenuItem.svelte';
|
||||
import GlassMenu from './ui/GlassMenu.svelte';
|
||||
import SearchBox from './SearchBox.svelte';
|
||||
import { themeMode } from '$lib/theme/mode.svelte';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
accountUrl?: string | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
let { user, accountUrl }: Props = $props();
|
||||
|
||||
let menuOpen = $state(false);
|
||||
let menuRef: HTMLDivElement | undefined = $state();
|
||||
@@ -21,61 +29,92 @@
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-line bg-bg/85 page-x backdrop-blur-md md:hidden"
|
||||
class="sticky top-0 z-40 flex flex-col gap-2.5 border-b border-hairline bg-bg/85 page-x pt-3 pb-3 backdrop-blur-md sm:hidden"
|
||||
>
|
||||
<a href="/" class="font-display text-lg font-semibold tracking-tight text-fg">Musify</a>
|
||||
<div class="flex h-9 items-center justify-between">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-2 font-display text-[17.5px] font-semibold tracking-[-0.02em] text-fg"
|
||||
>
|
||||
<span
|
||||
class="h-5.5 w-5.5 rounded-[7px] bg-[image:var(--mf-logo-grad)] shadow-[var(--mf-logo-glow)]"
|
||||
></span>
|
||||
Musify
|
||||
</a>
|
||||
|
||||
<div class="relative" bind:this={menuRef}>
|
||||
{#if user}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Tu cuenta"
|
||||
aria-expanded={menuOpen}
|
||||
class="flex items-center rounded-full"
|
||||
>
|
||||
{#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 place-items-center rounded-full bg-surface-2 text-sm uppercase">
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="animate-pop absolute top-full right-0 mt-2 w-56 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
<div class="relative" bind:this={menuRef}>
|
||||
{#if user}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Tu cuenta"
|
||||
aria-expanded={menuOpen}
|
||||
class="flex items-center rounded-full"
|
||||
>
|
||||
<MenuItem
|
||||
icon={Folder}
|
||||
label="Canciones subidas"
|
||||
href="/library"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={Upload}
|
||||
label="Subir música"
|
||||
href="/upload"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
<div class="-mx-1.5 my-1.5 border-t border-line"></div>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Salir" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
{#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 place-items-center rounded-full bg-surface-2 text-sm uppercase"
|
||||
>
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if menuOpen}
|
||||
<GlassMenu class="absolute top-full right-0 mt-2 w-56 overflow-hidden">
|
||||
<MenuItem
|
||||
icon={User}
|
||||
label="Ver perfil"
|
||||
href="/u/{user.sub}"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={Folder}
|
||||
label="Canciones subidas"
|
||||
href="/library"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={Upload}
|
||||
label="Subir música"
|
||||
href="/upload"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
{#if accountUrl}
|
||||
<MenuItem
|
||||
icon={Settings}
|
||||
label="Ajustes"
|
||||
href={accountUrl}
|
||||
target="_blank"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
<MenuItem
|
||||
icon={themeMode.current === 'dark' ? Sun : Moon}
|
||||
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
|
||||
onclick={() => themeMode.toggle()}
|
||||
/>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
|
||||
</form>
|
||||
</GlassMenu>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/login"
|
||||
data-sveltekit-reload
|
||||
aria-label="Iniciar sesión"
|
||||
class="flex items-center rounded-full p-1.5 text-fg-3 transition hover:bg-hover hover:text-fg"
|
||||
>
|
||||
<LogIn class="h-6 w-6" strokeWidth={2} />
|
||||
</a>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/login"
|
||||
data-sveltekit-reload
|
||||
aria-label="Iniciar sesión"
|
||||
class="flex items-center rounded-full p-1.5 text-fg-3 transition hover:bg-hover hover:text-fg"
|
||||
>
|
||||
<LogIn class="h-6 w-6" strokeWidth={2} />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchBox class="w-full" />
|
||||
</header>
|
||||
|
||||
<svelte:window onclick={onDocumentClick} />
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { isSectionActive } from '$lib/navigation.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import Home from '@lucide/svelte/icons/house';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
const links = $derived([
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
{ href: '/explore', label: 'Buscar', icon: Search },
|
||||
...(user
|
||||
? [
|
||||
{ href: '/playlists', label: 'Listas', icon: Music },
|
||||
{ href: '/library', label: 'Subidas', icon: Folder }
|
||||
]
|
||||
: [])
|
||||
]);
|
||||
|
||||
function isActive(href: string) {
|
||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="fixed inset-x-0 bottom-0 z-50 border-t border-line bg-bg/95 backdrop-blur-md md:hidden"
|
||||
style="padding-bottom:var(--mf-safe-b)"
|
||||
>
|
||||
<ul class="grid h-[var(--mf-nav-h)]" style="grid-template-columns:repeat({links.length}, 1fr)">
|
||||
{#each links as link (link.href)}
|
||||
{@const active = isActive(link.href)}
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex h-full flex-col items-center justify-center gap-1 transition-colors {active
|
||||
? 'text-fg'
|
||||
: 'text-fg-3'}"
|
||||
>
|
||||
<link.icon class="h-[22px] w-[22px] {active ? 'text-accent-soft' : ''}" />
|
||||
<span class="text-[11px] leading-none font-medium">{link.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let { class: klass = '', placeholder = 'Canciones, artistas, álbumes o playlists' }: Props =
|
||||
$props();
|
||||
|
||||
let query = $state('');
|
||||
let searchInput: HTMLInputElement | undefined = $state();
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function navigateSearch() {
|
||||
const term = query.trim();
|
||||
goto(term ? `/explore?q=${encodeURIComponent(term)}` : '/explore', {
|
||||
replaceState: page.url.pathname === '/explore',
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
const term = query.trim();
|
||||
const onExplore = page.url.pathname === '/explore';
|
||||
if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return;
|
||||
if (term.length === 0 && !onExplore) return;
|
||||
searchTimeout = setTimeout(navigateSearch, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function clearQuery() {
|
||||
clearTimeout(searchTimeout);
|
||||
query = '';
|
||||
if (page.url.pathname === '/explore') navigateSearch();
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
function onSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
clearTimeout(searchTimeout);
|
||||
navigateSearch();
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
clearTimeout(searchTimeout);
|
||||
navigateSearch();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative min-w-0 {klass}">
|
||||
<form onsubmit={onSubmit}>
|
||||
<label
|
||||
class="flex h-9.5 w-full items-center gap-2.5 rounded-[11px] bg-white/[3.5%] px-3.5 transition focus-within:bg-white/[6%]"
|
||||
>
|
||||
<Search class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
|
||||
<input
|
||||
type="text"
|
||||
bind:this={searchInput}
|
||||
bind:value={query}
|
||||
oninput={onInput}
|
||||
onkeydown={onKeydown}
|
||||
{placeholder}
|
||||
class="w-full min-w-0 bg-transparent text-[13px] text-fg placeholder:text-muted focus:outline-none"
|
||||
/>
|
||||
{#if query}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearQuery}
|
||||
aria-label="Borrar búsqueda"
|
||||
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,117 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { isSectionActive } from '$lib/navigation.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import { createPlaylistModal } from '$lib/playlists.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import PlaylistArt from './ui/PlaylistArt.svelte';
|
||||
import Home from '@lucide/svelte/icons/house';
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
import Compass from '@lucide/svelte/icons/compass';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
import Disc from '@lucide/svelte/icons/disc-2';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
|
||||
interface SidebarPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
coverTrackIds: (string | number)[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
playlists: SidebarPlaylist[];
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
let { user, playlists }: Props = $props();
|
||||
|
||||
let navBox: HTMLElement | undefined = $state();
|
||||
let pillTop = $state(0);
|
||||
let pillHeight = $state(0);
|
||||
let pillVisible = $state(false);
|
||||
let animate = $state(false);
|
||||
|
||||
const mainLinks = $derived([
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
...(user ? [{ href: '/playlists', label: 'Listas', icon: Music }] : [])
|
||||
]);
|
||||
|
||||
const secondaryLinks = $derived(
|
||||
const navLinks = $derived(
|
||||
user
|
||||
? [
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
{ href: '/explore', label: 'Descubrir', icon: Compass },
|
||||
{ href: '/playlists', label: 'Playlists', icon: ListMusic },
|
||||
{ href: '/liked', label: 'Me gusta', icon: Heart, count: liked.count },
|
||||
{ href: '/albums', label: 'Mis álbumes', icon: Disc },
|
||||
{ href: '/library', label: 'Canciones subidas', icon: Folder },
|
||||
{ href: '/upload', label: 'Subir música', icon: Upload }
|
||||
]
|
||||
: []
|
||||
: [{ href: '/explore', label: 'Descubrir', icon: Compass }]
|
||||
);
|
||||
|
||||
function isActive(href: string) {
|
||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void page.url.pathname;
|
||||
const box = navBox;
|
||||
if (!box) return;
|
||||
const active = box.querySelector<HTMLElement>('[data-active="true"]');
|
||||
if (!active) {
|
||||
pillVisible = false;
|
||||
return;
|
||||
}
|
||||
const boxRect = box.getBoundingClientRect();
|
||||
const rect = active.getBoundingClientRect();
|
||||
pillTop = rect.top - boxRect.top;
|
||||
pillHeight = rect.height;
|
||||
pillVisible = true;
|
||||
if (!animate) requestAnimationFrame(() => (animate = true));
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet navLink(link: { href: string; label: string; icon: typeof Home })}
|
||||
{@const active = isActive(link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
data-active={active}
|
||||
class="relative flex items-center gap-3.5 rounded-control px-4 py-2.5 text-sm transition-colors duration-100 {active
|
||||
? 'text-fg'
|
||||
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
|
||||
>
|
||||
<link.icon class="h-5 w-5 shrink-0 {active ? 'text-accent-soft' : ''}" />
|
||||
{link.label}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
{#snippet groupLabel(label: string)}
|
||||
<p class="mb-4 px-4 text-sm font-semibold tracking-[0.12em] text-fg-2 uppercase">
|
||||
{label}
|
||||
</p>
|
||||
{/snippet}
|
||||
|
||||
<aside
|
||||
class="sticky top-0 hidden h-screen shrink-0 flex-col border-r border-line bg-bg md:flex"
|
||||
class="hidden shrink-0 flex-col border-r border-hairline bg-[image:var(--mf-sidebar-bg)] pt-5.5 pr-3 pl-5 transition-[background] duration-[600ms] ease-out lg:flex"
|
||||
style="width:var(--mf-sidebar-w)"
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-1 flex-col gap-6 overflow-y-auto px-4.5 py-6 md:pt-8"
|
||||
bind:this={navBox}
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-4.5 z-0 overflow-hidden rounded-control border border-line bg-surface {animate
|
||||
? 'transition-[top,height] duration-200 ease-in-out'
|
||||
: ''}"
|
||||
style="top:{pillTop}px; height:{pillHeight}px; opacity:{pillVisible ? 1 : 0}"
|
||||
>
|
||||
<span class="absolute inset-y-0 left-0 w-0.75 rounded-r-full bg-accent-soft"></span>
|
||||
<a href="/" class="flex items-center gap-2.5 px-1.5 pb-6.5">
|
||||
<span class="font-display text-[17.5px] font-semibold tracking-[-0.02em] text-fg">Musify</span>
|
||||
</a>
|
||||
|
||||
<nav class="flex flex-col gap-0.5">
|
||||
{#each navLinks as link (link.href)}
|
||||
{@const active = isActive(link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex items-center gap-3 rounded-[10px] px-3 py-2.25 text-[13.5px] font-medium transition-colors duration-150 {active
|
||||
? 'bg-surface-hover text-fg'
|
||||
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
|
||||
>
|
||||
<link.icon class="h-[17px] w-[17px] shrink-0" strokeWidth={1.6} />
|
||||
<span class="flex-1 truncate">{link.label}</span>
|
||||
{#if link.count}
|
||||
<span class="text-[11px] text-muted tabular-nums">{link.count}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{#if user}
|
||||
<div class="mx-3 mt-6 mb-3.5 h-px bg-white/5"></div>
|
||||
<div class="flex items-center justify-between px-3 pb-1.5">
|
||||
<span class="text-[10.5px] font-semibold tracking-[0.13em] text-muted uppercase"
|
||||
>Tus playlists</span
|
||||
>
|
||||
{#if playlists.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => createPlaylistModal.show()}
|
||||
aria-label="Crear playlist"
|
||||
class="text-base text-muted transition-colors hover:text-fg"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{@render groupLabel('Navegación')}
|
||||
<nav class="relative z-10 flex flex-col gap-2">
|
||||
{#each mainLinks as link (link.href)}
|
||||
{@render navLink(link)}
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-4.5">
|
||||
{#each playlists as playlist (playlist.id)}
|
||||
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
||||
<a
|
||||
href="/playlists/{playlist.id}"
|
||||
class="flex items-center gap-2.75 rounded-[10px] px-3 py-1.75 transition-colors duration-150 {active
|
||||
? 'bg-surface-hover'
|
||||
: 'hover:bg-hover'}"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
version={playlist.updatedAt}
|
||||
size="small"
|
||||
class="h-8.5 w-8.5 shrink-0 overflow-hidden rounded-lg opacity-90"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[13.5px] font-medium {active ? 'text-fg' : 'text-fg-2'}">
|
||||
{playlist.name}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => createPlaylistModal.show()}
|
||||
class="flex items-center gap-2 rounded-[10px] px-3 py-2 text-[12px] text-muted transition-colors hover:bg-hover hover:text-fg-2"
|
||||
>
|
||||
<Plus class="h-3.5 w-3.5" strokeWidth={1.8} />
|
||||
Crear tu primera lista
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if secondaryLinks.length > 0}
|
||||
<div>
|
||||
{@render groupLabel('Tu música')}
|
||||
<nav class="relative z-10 flex flex-col gap-1">
|
||||
{#each secondaryLinks as link (link.href)}
|
||||
{@render navLink(link)}
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { isSectionActive } from '$lib/navigation.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import Home from '@lucide/svelte/icons/house';
|
||||
import Compass from '@lucide/svelte/icons/compass';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { user, class: klass = '' }: Props = $props();
|
||||
|
||||
const links = $derived(
|
||||
user
|
||||
? [
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
{ href: '/explore', label: 'Descubrir', icon: Compass },
|
||||
{ href: '/playlists', label: 'Playlists', icon: ListMusic },
|
||||
{ href: '/liked', label: 'Me gusta', icon: Heart }
|
||||
]
|
||||
: [{ href: '/explore', label: 'Descubrir', icon: Compass }]
|
||||
);
|
||||
|
||||
function isActive(href: string) {
|
||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="pointer-events-auto flex items-center justify-around rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] p-1.75 backdrop-blur-[22px] transition-[background] duration-[600ms] lg:hidden {klass}"
|
||||
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
|
||||
>
|
||||
{#each links as link (link.href)}
|
||||
{@const active = isActive(link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex flex-col items-center gap-1.25 rounded-xl px-2.5 py-1.5 {active
|
||||
? 'text-fg'
|
||||
: 'text-fg-2'}"
|
||||
>
|
||||
<link.icon class="h-[17px] w-[17px]" strokeWidth={1.6} />
|
||||
<span class="text-[10px] font-medium">{link.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -1,220 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type { SessionUser, YouTubeSong } from '$lib/types';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import {
|
||||
queueItemForTarget,
|
||||
targetArtist,
|
||||
targetCoverSrc,
|
||||
targetExplicit,
|
||||
targetId,
|
||||
targetTitle,
|
||||
type TrackTarget
|
||||
} from '$lib/tracks';
|
||||
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import LogOut from '@lucide/svelte/icons/log-out';
|
||||
import User from '@lucide/svelte/icons/user';
|
||||
import Settings from '@lucide/svelte/icons/settings';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import MenuItem from './ui/MenuItem.svelte';
|
||||
import Cover from './ui/Cover.svelte';
|
||||
import ExplicitBadge from './ui/ExplicitBadge.svelte';
|
||||
import GlassMenu from './ui/GlassMenu.svelte';
|
||||
import SearchBox from './SearchBox.svelte';
|
||||
import { themeMode } from '$lib/theme/mode.svelte';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
accountUrl?: string | null;
|
||||
}
|
||||
|
||||
const QUICK_RESULTS_LIMIT = 5;
|
||||
let { user, accountUrl }: Props = $props();
|
||||
|
||||
interface QuickSearchTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
artist?: string | null;
|
||||
isExplicit?: boolean;
|
||||
}
|
||||
|
||||
interface QuickSearchResponse {
|
||||
items: { track?: QuickSearchTrack | null; youTubeSong?: YouTubeSong | null }[];
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<TrackTarget[]>([]);
|
||||
let resultsOpen = $state(false);
|
||||
let searching = $state(false);
|
||||
let menuOpen = $state(false);
|
||||
let menuRef: HTMLDivElement | undefined = $state();
|
||||
let searchRef: HTMLDivElement | undefined = $state();
|
||||
let searchInput: HTMLInputElement | undefined = $state();
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let searchToken = 0;
|
||||
|
||||
function clearQuery() {
|
||||
query = '';
|
||||
results = [];
|
||||
resultsOpen = false;
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
const term = query.trim();
|
||||
if (term.length < SEARCH_MIN_LENGTH) {
|
||||
results = [];
|
||||
resultsOpen = false;
|
||||
return;
|
||||
}
|
||||
resultsOpen = true;
|
||||
searchTimeout = setTimeout(() => runQuickSearch(term), SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function runQuickSearch(term: string) {
|
||||
const token = ++searchToken;
|
||||
searching = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ name: term, pageSize: String(QUICK_RESULTS_LIMIT) });
|
||||
const res = await fetch(`/api/tracks?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = (await res.json()) as QuickSearchResponse;
|
||||
if (token !== searchToken) return;
|
||||
results = data.items
|
||||
.flatMap((item): TrackTarget[] => {
|
||||
if (item.track) return [{ kind: 'local', track: item.track }];
|
||||
if (item.youTubeSong) return [{ kind: 'youtube', song: item.youTubeSong }];
|
||||
return [];
|
||||
})
|
||||
.slice(0, QUICK_RESULTS_LIMIT);
|
||||
} catch {
|
||||
if (token === searchToken) results = [];
|
||||
} finally {
|
||||
if (token === searchToken) searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function playResult(index: number) {
|
||||
player.playOrToggle(results.map(queueItemForTarget), index);
|
||||
resultsOpen = false;
|
||||
}
|
||||
|
||||
function onSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
clearTimeout(searchTimeout);
|
||||
resultsOpen = false;
|
||||
const q = query.trim();
|
||||
goto(q ? `/explore?q=${encodeURIComponent(q)}` : '/explore');
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
|
||||
if (searchRef && !searchRef.contains(event.target as Node)) resultsOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-30 hidden grid-cols-[1fr_minmax(0,3fr)_1fr] items-center gap-4 border-b border-line bg-bg/70 px-8 py-4 backdrop-blur-lg md:grid"
|
||||
>
|
||||
<div></div>
|
||||
|
||||
<div class="relative mx-auto w-full max-w-2xl" bind:this={searchRef}>
|
||||
<form onsubmit={onSubmit}>
|
||||
<label
|
||||
class="flex w-full items-center gap-2.5 rounded-full border border-line bg-surface px-4 py-2.5 transition focus-within:border-accent-soft/60"
|
||||
>
|
||||
<Search class="h-4 w-4 shrink-0 text-fg-2" />
|
||||
<input
|
||||
type="text"
|
||||
bind:this={searchInput}
|
||||
bind:value={query}
|
||||
oninput={onInput}
|
||||
onfocus={() => (resultsOpen = results.length > 0)}
|
||||
placeholder="Buscar"
|
||||
class="w-full bg-transparent text-sm text-fg placeholder:text-fg-2 focus:outline-none"
|
||||
/>
|
||||
{#if query}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearQuery}
|
||||
aria-label="Borrar búsqueda"
|
||||
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{#if resultsOpen && (results.length > 0 || searching)}
|
||||
<div
|
||||
class="animate-pop absolute top-full right-0 left-0 z-20 mt-2 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
>
|
||||
{#if searching && results.length === 0}
|
||||
<p class="px-3 py-2.5 text-sm text-fg-2">Buscando…</p>
|
||||
{:else}
|
||||
{#each results as target, i (targetId(target))}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playResult(i)}
|
||||
class="group/result flex w-full min-w-0 items-center gap-3 rounded-control p-2 text-left transition hover:bg-hover"
|
||||
>
|
||||
<Cover
|
||||
trackId={targetId(target)}
|
||||
src={targetCoverSrc(target)}
|
||||
size="small"
|
||||
alt={targetTitle(target)}
|
||||
class="h-9 w-9 shrink-0 rounded-control shadow-art"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(target)}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-sm text-fg">{targetTitle(target)}</span>
|
||||
</div>
|
||||
{#if targetArtist(target)}
|
||||
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex">
|
||||
<div class="flex shrink-0 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => history.back()}
|
||||
aria-label="Atrás"
|
||||
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-white/[3.5%] text-fg-2 transition-colors hover:bg-white/7 hover:text-fg"
|
||||
>
|
||||
<ChevronLeft class="h-[15px] w-[15px]" strokeWidth={1.9} />
|
||||
</button>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-white/[3.5%] text-muted"
|
||||
>
|
||||
<ChevronRight class="h-[15px] w-[15px]" strokeWidth={1.9} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-6">
|
||||
<SearchBox class="max-w-[480px] flex-1" />
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<div class="relative shrink-0" bind:this={menuRef}>
|
||||
{#if user}
|
||||
<div class="relative" bind:this={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Tu cuenta"
|
||||
class="block shrink-0 rounded-full transition active:scale-95"
|
||||
>
|
||||
{#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 place-items-center rounded-full bg-surface-2 text-sm font-semibold text-fg uppercase"
|
||||
>
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="animate-pop absolute top-full right-0 z-20 mt-2 w-40 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Tu cuenta"
|
||||
class="block h-8.25 w-8.25 shrink-0 overflow-hidden rounded-full border border-white/10 transition active:scale-95"
|
||||
>
|
||||
{#if user.picture}
|
||||
<img src={user.picture} alt="" class="h-full w-full object-cover opacity-90" />
|
||||
{:else}
|
||||
<span
|
||||
class="grid h-full w-full place-items-center bg-surface-2 text-xs font-semibold text-fg uppercase"
|
||||
>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Salir" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<GlassMenu class="absolute top-full right-0 z-20 mt-2 w-52 overflow-hidden">
|
||||
<MenuItem
|
||||
icon={User}
|
||||
label="Ver perfil"
|
||||
href="/u/{user.sub}"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
{#if accountUrl}
|
||||
<MenuItem
|
||||
icon={Settings}
|
||||
label="Ajustes"
|
||||
href={accountUrl}
|
||||
target="_blank"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
<MenuItem
|
||||
icon={themeMode.current === 'dark' ? Sun : Moon}
|
||||
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
|
||||
onclick={() => themeMode.toggle()}
|
||||
/>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
|
||||
</form>
|
||||
</GlassMenu>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/login"
|
||||
data-sveltekit-reload
|
||||
class="shrink-0 rounded-full bg-accent-soft px-4 py-2 text-sm font-semibold text-on-accent transition hover:brightness-110"
|
||||
class="shrink-0 rounded-full bg-accent-soft px-4 py-2 text-sm font-semibold text-ink transition hover:brightness-110"
|
||||
>
|
||||
Iniciar sesión
|
||||
</a>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
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 GlassMenu from '$lib/components/ui/GlassMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
menu: ContextMenuState;
|
||||
@@ -48,24 +49,21 @@
|
||||
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;"
|
||||
>
|
||||
<GlassMenu class="fixed z-40 w-60" 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"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover"
|
||||
>
|
||||
<ListPlus class="h-5 w-5 shrink-0" strokeWidth={2} />
|
||||
<span class="text-base">Reproducir a continuación</span>
|
||||
<ListPlus class="h-[18px] w-[18px] shrink-0" strokeWidth={2} />
|
||||
<span>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"
|
||||
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-[13.5px] font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
|
||||
>
|
||||
<span class="flex-1 cursor-default text-base">Añadir a una playlist</span>
|
||||
<span class="flex-1 cursor-default">Añadir a una playlist</span>
|
||||
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
|
||||
</div>
|
||||
<div
|
||||
@@ -99,7 +97,7 @@
|
||||
{/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"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg transition hover:bg-hover"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
@@ -116,4 +114,4 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</GlassMenu>
|
||||
|
||||
@@ -2,44 +2,27 @@
|
||||
import TrackInfo from './TrackInfo.svelte';
|
||||
import TransportControls from './TransportControls.svelte';
|
||||
import SeekBar from './SeekBar.svelte';
|
||||
import VolumeControl from './VolumeControl.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import PlayerExtras from './PlayerExtras.svelte';
|
||||
</script>
|
||||
|
||||
{#if player.error}
|
||||
<div
|
||||
class="fixed inset-x-0 z-40 page-x py-2 md:left-[var(--mf-sidebar-w)] md:z-50"
|
||||
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-player-h))"
|
||||
>
|
||||
<Alert tone="danger">{player.error}</Alert>
|
||||
<div
|
||||
class="animate-enter pointer-events-auto flex items-center gap-4.5 rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 shadow-player backdrop-blur-[22px] transition-[background] duration-[600ms] sm:hidden"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<TrackInfo compact />
|
||||
</div>
|
||||
{/if}
|
||||
<TransportControls compact />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="animate-enter fixed right-0 left-0 z-40 h-[var(--mf-player-h)] border-t border-line bg-bg md:left-[var(--mf-sidebar-w)] md:z-50"
|
||||
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b))"
|
||||
class="animate-enter pointer-events-auto hidden items-center gap-5 rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 shadow-player backdrop-blur-[22px] transition-[background] duration-[600ms] sm:flex"
|
||||
>
|
||||
<div class="absolute inset-x-0 top-0 md:hidden">
|
||||
<SeekBar compact />
|
||||
</div>
|
||||
|
||||
<div class="flex h-full items-center gap-3 page-x md:hidden">
|
||||
<div class="min-w-0 flex-1">
|
||||
<TrackInfo />
|
||||
</div>
|
||||
<TransportControls compact />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden h-full items-center gap-5 px-8 md:grid"
|
||||
style="grid-template-columns:minmax(200px,1fr) minmax(0,3fr) minmax(200px,1fr)"
|
||||
>
|
||||
<div class="w-64 min-w-0 shrink-0">
|
||||
<TrackInfo />
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<TransportControls />
|
||||
<SeekBar />
|
||||
</div>
|
||||
<VolumeControl />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col items-center gap-2">
|
||||
<TransportControls />
|
||||
<SeekBar />
|
||||
</div>
|
||||
<PlayerExtras />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import PlayerBar from './PlayerBar.svelte';
|
||||
import TabsBar from '../TabsBar.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
const hasTrack = $derived(player.currentId !== null);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex flex-col gap-2.5 px-4 pb-4 lg:left-[var(--mf-sidebar-w)]"
|
||||
>
|
||||
{#if player.error}
|
||||
<div class="pointer-events-auto">
|
||||
<Alert tone="danger">{player.error}</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
{#if hasTrack}
|
||||
<PlayerBar />
|
||||
{/if}
|
||||
<TabsBar {user} />
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import VolumeControl from './VolumeControl.svelte';
|
||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
||||
import AlignJustify from '@lucide/svelte/icons/align-justify';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
</script>
|
||||
|
||||
<div class="hidden w-64 shrink-0 items-center justify-end gap-3.5 sm:flex">
|
||||
<span aria-hidden="true" class="text-fg-3">
|
||||
<AlignJustify class="h-[15px] w-[15px]" strokeWidth={1.8} />
|
||||
</span>
|
||||
<VolumeControl />
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => queuePanel.toggle()}
|
||||
aria-pressed={queuePanel.open}
|
||||
aria-label="Cola de reproducción"
|
||||
class="transition-colors hover:text-fg {queuePanel.open ? 'text-fg' : 'text-fg-2'}"
|
||||
>
|
||||
<ListMusic class="h-4 w-4" strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
|
||||
const upcoming = $derived.by(() => {
|
||||
const idx = player.tracks.findIndex((t) => t.id === player.currentId);
|
||||
return idx === -1 ? player.tracks : player.tracks.slice(idx + 1);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if queuePanel.open}
|
||||
<aside
|
||||
transition:fly={{ x: 24, duration: 220, easing: cubicOut }}
|
||||
class="hidden shrink-0 flex-col border-l border-hairline bg-[image:var(--mf-panel-bg)] px-4 pt-5 transition-[background] duration-[600ms] sm:flex"
|
||||
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
|
||||
>
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h2 class="font-display text-[14.5px] font-semibold tracking-[-0.02em] text-fg">En cola</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => queuePanel.close()}
|
||||
aria-label="Cerrar cola"
|
||||
class="text-base text-fg-3 transition-colors hover:text-fg"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if player.current.id}
|
||||
<p class="mb-2.75 text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase">
|
||||
Reproduciendo
|
||||
</p>
|
||||
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
|
||||
<Cover
|
||||
trackId={player.current.id}
|
||||
src={player.current.coverUrl}
|
||||
size="small"
|
||||
alt={player.current.title}
|
||||
class="h-10 w-10 shrink-0 rounded-[9px]"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[12.5px] font-semibold text-accent-soft">
|
||||
{player.current.title}
|
||||
</div>
|
||||
<ArtistLink
|
||||
name={player.current.artist || '—'}
|
||||
ownerUserId={player.current.ownerUserId}
|
||||
class="mt-0.75 text-[11px] text-fg-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-2.75 flex items-center justify-between gap-3">
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase"
|
||||
>
|
||||
A continuación
|
||||
</span>
|
||||
{#if upcoming.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.clearUpcoming()}
|
||||
class="shrink-0 text-[11px] text-muted transition-colors hover:text-fg"
|
||||
>
|
||||
Vaciar
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-px overflow-y-auto">
|
||||
{#each upcoming as track, i (track.id)}
|
||||
{@const index = player.tracks.length - upcoming.length + i}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => player.playQueueIndex(index)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
player.playQueueIndex(index);
|
||||
}}
|
||||
class="flex items-center gap-3 rounded-[11px] p-2.25 text-left hover:bg-hover"
|
||||
>
|
||||
<Cover
|
||||
trackId={track.id}
|
||||
src={track.coverUrl}
|
||||
size="small"
|
||||
alt={track.title}
|
||||
class="h-9 w-9 shrink-0 rounded-lg opacity-90"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[12.5px] font-medium text-fg">{track.title}</div>
|
||||
<ArtistLink
|
||||
name={track.artist || '—'}
|
||||
ownerUserId={track.ownerUserId}
|
||||
class="mt-0.5 text-[11px] text-fg-3"
|
||||
/>
|
||||
</div>
|
||||
{#if track.duration}
|
||||
<span class="shrink-0 text-[11px] text-muted tabular-nums"
|
||||
>{fmtTime(track.duration)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
@@ -27,26 +27,21 @@
|
||||
></div>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="flex w-full max-w-172 items-center gap-3">
|
||||
<span class="text-right text-xs text-muted tabular-nums">
|
||||
<div class="flex w-full max-w-135 items-center gap-2.75">
|
||||
<span class="min-w-8 text-right text-[10.5px] text-fg-3 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"
|
||||
class="group relative h-3.5 flex-1 cursor-pointer"
|
||||
>
|
||||
<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-on-art 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>
|
||||
<div class="h-[3.5px] w-full overflow-hidden rounded-full bg-track">
|
||||
<div class="h-full rounded-full bg-accent" style="width:{player.progressPercent}%"></div>
|
||||
</div>
|
||||
</button>
|
||||
<span class="text-xs text-muted tabular-nums">
|
||||
<span class="min-w-8 text-[10.5px] text-fg-3 tabular-nums">
|
||||
{fmtTime(player.current.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
|
||||
interface Props {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
let { compact = false }: Props = $props();
|
||||
|
||||
const isLiked = $derived(player.current.id !== '' && liked.isLiked(player.current.id));
|
||||
|
||||
function toggleLike() {
|
||||
if (!player.current.id) return;
|
||||
liked.toggle({
|
||||
id: player.current.id,
|
||||
title: player.current.title,
|
||||
artist: player.current.artist,
|
||||
coverUrl: player.current.coverUrl,
|
||||
source: player.current.source,
|
||||
explicit: player.current.explicit,
|
||||
ownerUserId: player.current.ownerUserId
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2.5 md:gap-3.25">
|
||||
{#if player.current.id}
|
||||
<Cover
|
||||
trackId={player.current.id}
|
||||
src={player.current.coverUrl}
|
||||
size="small"
|
||||
alt={player.current.title}
|
||||
class="h-11 w-11 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13"
|
||||
class="h-11 w-11 shrink-0 rounded-[11px] shadow-[0_8px_20px_rgba(0,0,0,0.5)] md:h-12 md:w-12"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="h-11 w-11 shrink-0 rounded-lg bg-surface shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13"
|
||||
class="h-11 w-11 shrink-0 rounded-[11px] bg-surface shadow-[0_8px_20px_rgba(0,0,0,0.5)] md:h-12 md:w-12"
|
||||
></div>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<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>
|
||||
<span class="truncate text-[13px] font-semibold tracking-[-0.01em] text-fg"
|
||||
>{player.current.title}</span
|
||||
>
|
||||
</div>
|
||||
<div class="truncate text-sm text-muted">{player.current.artist || '—'}</div>
|
||||
<ArtistLink
|
||||
name={player.current.artist || '—'}
|
||||
ownerUserId={player.current.ownerUserId}
|
||||
class="mt-0.5 text-[11.5px] text-fg-3"
|
||||
/>
|
||||
</div>
|
||||
{#if !compact}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleLike}
|
||||
aria-label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
|
||||
aria-pressed={isLiked}
|
||||
class="shrink-0 transition hover:opacity-80 {isLiked ? 'text-accent' : 'text-fg-3'}"
|
||||
>
|
||||
<Heart class="h-4 w-4" fill={isLiked ? 'currentColor' : 'none'} strokeWidth={1.7} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||
import Repeat from '@lucide/svelte/icons/repeat';
|
||||
|
||||
interface Props {
|
||||
compact?: boolean;
|
||||
@@ -10,11 +12,23 @@
|
||||
const sideClass = $derived(
|
||||
compact
|
||||
? 'grid h-10 w-10 place-items-center text-fg opacity-70 transition active:scale-90'
|
||||
: 'flex text-fg opacity-70 transition hover:opacity-100 active:scale-90'
|
||||
: 'flex text-fg-2 transition-colors hover:text-fg active:scale-90'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex shrink-0 items-center {compact ? 'gap-0.5' : 'gap-5'}">
|
||||
<div class="flex shrink-0 items-center {compact ? 'gap-0.5' : 'gap-3.5'}">
|
||||
{#if !compact}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.toggleShuffle()}
|
||||
aria-pressed={player.shuffle}
|
||||
aria-label="Aleatorio"
|
||||
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}"
|
||||
>
|
||||
<Shuffle class="h-[15px] w-[15px]" strokeWidth={1.8} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button type="button" onclick={() => player.previous()} aria-label="Anterior" class={sideClass}>
|
||||
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
|
||||
<rect x="0" y="0" width="2.5" height="14" rx="1" />
|
||||
@@ -26,16 +40,16 @@
|
||||
type="button"
|
||||
onclick={() => player.toggle()}
|
||||
aria-label={player.playing ? 'Pausar' : 'Reproducir'}
|
||||
class="grid h-[36px] w-[36px] shrink-0 place-items-center rounded-full bg-fg text-bg shadow-lg transition duration-100 active:scale-95"
|
||||
class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-cta-strong text-ink transition duration-150 hover:brightness-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 width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
|
||||
<rect x="3" y="1" width="3" height="13" rx="1" />
|
||||
<rect x="9" y="1" width="3" height="13" 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 width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
|
||||
<path d="M3 1.5v12l10-6z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -46,4 +60,16 @@
|
||||
<rect x="13.5" y="0" width="2.5" height="14" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if !compact}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.toggleRepeat()}
|
||||
aria-pressed={player.repeat}
|
||||
aria-label="Repetir"
|
||||
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}"
|
||||
>
|
||||
<Repeat class="h-[15px] w-[15px]" strokeWidth={1.8} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import Volume2 from '@lucide/svelte/icons/volume-2';
|
||||
|
||||
function handleVolumeChange(event: Event) {
|
||||
const target = event.currentTarget as HTMLInputElement;
|
||||
const newVolume = Number(target.value);
|
||||
player.setVolume(newVolume);
|
||||
player.setVolume(Number(target.value));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-end gap-3.5">
|
||||
<div class="flex items-center gap-2.25">
|
||||
<Volume2 class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
@@ -17,7 +18,7 @@
|
||||
oninput={handleVolumeChange}
|
||||
aria-label="Volumen"
|
||||
class="mf-volume"
|
||||
style="background:linear-gradient(to right, var(--color-accent-soft) {player.volume}%, var(--color-track) {player.volume}%)"
|
||||
style="background:linear-gradient(to right, var(--mf-text-2) {player.volume}%, var(--mf-track) {player.volume}%)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -25,8 +26,8 @@
|
||||
.mf-volume {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
width: 84px;
|
||||
height: 3.5px;
|
||||
border-radius: 100px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
@@ -34,17 +35,17 @@
|
||||
.mf-volume::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-fg);
|
||||
background: var(--mf-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mf-volume::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-fg);
|
||||
background: var(--mf-text);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import Disc3 from '@lucide/svelte/icons/disc-3';
|
||||
import ImageDropzone from './ImageDropzone.svelte';
|
||||
import Button from './Button.svelte';
|
||||
import Field from './Field.svelte';
|
||||
@@ -59,11 +60,18 @@
|
||||
if (result.type === 'success') onSuccess?.();
|
||||
};
|
||||
}}
|
||||
class="mt-4 w-full max-w-2xl space-y-4"
|
||||
class="mt-5 w-full max-w-2xl space-y-5"
|
||||
>
|
||||
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
|
||||
<Field label="Portada" optional={!requireCover}>
|
||||
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} required={requireCover} />
|
||||
<Field>
|
||||
<ImageDropzone
|
||||
name="cover"
|
||||
fallbackUrl={coverFallbackUrl}
|
||||
required={requireCover}
|
||||
icon={Disc3}
|
||||
gradient
|
||||
class="h-36 w-36 rounded-[16px]"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-4">
|
||||
@@ -78,7 +86,7 @@
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Año de publicación" for="album-year" optional>
|
||||
<Field label="Año de publicación" for="album-year">
|
||||
<Input
|
||||
id="album-year"
|
||||
name="releaseYear"
|
||||
@@ -92,7 +100,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Descripción" for="album-description" optional>
|
||||
<Field label="Descripción" for="album-description">
|
||||
<Textarea
|
||||
id="album-description"
|
||||
name="description"
|
||||
|
||||
@@ -11,10 +11,14 @@
|
||||
|
||||
const tones = {
|
||||
neutral: 'border-line bg-surface text-fg-2',
|
||||
danger: 'border-danger/40 bg-danger/10 text-danger-fg'
|
||||
danger: 'border-danger/20 bg-danger/10 text-danger-fg'
|
||||
};
|
||||
</script>
|
||||
|
||||
<p class="rounded-control border px-4 py-3 text-base {tones[tone]} {klass}">
|
||||
<p
|
||||
class="rounded-control border px-4 py-3 text-sm shadow-menu backdrop-blur-md {tones[
|
||||
tone
|
||||
]} {klass}"
|
||||
>
|
||||
{@render children()}
|
||||
</p>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: string;
|
||||
imageUrl?: string | null;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { name, size = 108 }: Props = $props();
|
||||
let { name, imageUrl, size = 108 }: Props = $props();
|
||||
|
||||
const initials = $derived(
|
||||
name
|
||||
@@ -21,11 +22,15 @@
|
||||
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-surface shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/artist:shadow-art-lg"
|
||||
style="width:{size}px;height:{size}px"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0"
|
||||
style="background:repeating-linear-gradient(135deg, rgba(255,255,255,0.12) 0 2px, transparent 2px 10px)"
|
||||
></div>
|
||||
<span class="relative text-lg font-semibold text-white/90">{initials}</span>
|
||||
{#if imageUrl}
|
||||
<img src={imageUrl} alt="" class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0"
|
||||
style="background:repeating-linear-gradient(135deg, rgba(255,255,255,0.12) 0 2px, transparent 2px 10px)"
|
||||
></div>
|
||||
<span class="relative text-lg font-semibold text-white/90">{initials}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full truncate text-sm text-fg">{name}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name?: string | null;
|
||||
ownerUserId?: string | number | null;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { name, ownerUserId, class: klass = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if name}
|
||||
{#if ownerUserId}
|
||||
<a
|
||||
href="/u/{ownerUserId}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="inline-block max-w-full truncate align-top hover:text-fg-2 hover:underline {klass}"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="block truncate {klass}">{name}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -3,7 +3,7 @@
|
||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'subtle' | 'danger';
|
||||
variant?: 'primary' | 'secondary' | 'accent' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
type?: 'button' | 'submit';
|
||||
href?: string;
|
||||
@@ -29,20 +29,22 @@
|
||||
}: 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'
|
||||
sm: 'px-4 py-2.5 text-[12.5px]',
|
||||
md: 'px-5 py-3 text-[13px]',
|
||||
lg: 'px-6 py-3.5 text-sm'
|
||||
};
|
||||
|
||||
const subtleButton = 'bg-white/[6%] text-fg-2 hover:bg-white/[9%] hover:text-fg';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-accent-soft text-on-accent active:scale-[0.97]',
|
||||
secondary: 'bg-bg text-fg ring-1 ring-line hover:bg-surface',
|
||||
subtle: 'bg-white/10 text-fg hover:bg-accent/18',
|
||||
primary: subtleButton,
|
||||
secondary: subtleButton,
|
||||
accent: 'bg-accent-btn text-accent-soft hover:bg-accent-btn-hover',
|
||||
danger: 'bg-danger text-on-accent hover:brightness-110'
|
||||
};
|
||||
|
||||
const classes = $derived(
|
||||
`inline-flex items-center justify-center gap-2 rounded-control font-medium transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
|
||||
`inline-flex items-center justify-center gap-2 rounded-xl font-semibold transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
let { checked = $bindable(false), name, class: klass = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<label class="flex items-center gap-3 text-base text-fg-2 {klass}">
|
||||
<label class="flex items-center gap-3 text-sm text-fg-2 {klass}">
|
||||
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -21,7 +21,7 @@
|
||||
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"
|
||||
class="pointer-events-none absolute h-3.5 w-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
label: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { label, class: klass = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="pointer-events-none absolute top-2 left-2 rounded-full bg-black/45 px-2 py-0.5 text-[10px] font-semibold tracking-[0.08em] text-white uppercase backdrop-blur-sm {klass}"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
@@ -13,24 +13,19 @@
|
||||
let { icon: Icon, title, description, class: klass = '', actions }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center rounded-panel border border-dashed border-line px-6 py-16 text-center {klass}"
|
||||
>
|
||||
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
|
||||
{#if Icon}
|
||||
<span
|
||||
class="grid h-16 w-16 place-items-center rounded-full text-on-accent"
|
||||
style="background:radial-gradient(circle at 30% 30%, color-mix(in oklch, var(--color-accent), white 15%), var(--color-accent))"
|
||||
>
|
||||
<Icon class="h-7 w-7" />
|
||||
</span>
|
||||
<Icon class="h-9 w-9 text-fg-2" strokeWidth={1.3} />
|
||||
{/if}
|
||||
{#if title}
|
||||
<h3 class="mt-6 font-display text-2xl font-medium tracking-tight text-fg">{title}</h3>
|
||||
<h3 class="mt-6 font-display text-xl font-semibold tracking-[-0.025em] text-fg">
|
||||
{title}
|
||||
</h3>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="mt-2 max-w-sm text-fg-2">{description}</p>
|
||||
<p class="mt-2.5 max-w-sm text-[13px] leading-[1.65] text-fg-2">{description}</p>
|
||||
{/if}
|
||||
{#if actions}
|
||||
<div class="mt-6 flex items-center gap-3">{@render actions()}</div>
|
||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-2.5">{@render actions()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: number;
|
||||
barWidth?: number;
|
||||
paused?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { size = 13, barWidth = 2, paused = false, class: klass = '' }: Props = $props();
|
||||
|
||||
const delays = [0, 0.3, 0.6];
|
||||
</script>
|
||||
|
||||
<div class="flex items-end justify-center gap-[2px] {klass}" style="height:{size}px">
|
||||
{#each delays as delay (delay)}
|
||||
<span
|
||||
class="rounded-full bg-accent"
|
||||
style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
||||
? 'paused'
|
||||
: 'running'}"
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -2,25 +2,25 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
label?: string;
|
||||
for?: string;
|
||||
optional?: boolean;
|
||||
class?: string;
|
||||
hint?: Snippet;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { label, for: forId, optional = false, class: klass = '', hint, children }: Props = $props();
|
||||
let { label, for: forId, 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>
|
||||
{#if label}
|
||||
<label for={forId} class="mb-2 block text-sm font-medium text-fg-2">
|
||||
{label}
|
||||
</label>
|
||||
{/if}
|
||||
{@render children()}
|
||||
{#if hint}
|
||||
<div class="mt-2 flex items-center justify-between gap-3 text-sm text-muted">
|
||||
<div class="mt-2 flex items-center justify-between gap-3 text-xs text-muted">
|
||||
{@render hint()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
style?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { class: klass = '', style = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="glass-panel animate-pop rounded-panel p-1.5 shadow-menu {klass}" {style}>
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
import type { LucideIcon } from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
fallbackUrl?: string;
|
||||
required?: boolean;
|
||||
icon?: LucideIcon;
|
||||
gradient?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -12,13 +15,16 @@
|
||||
name,
|
||||
fallbackUrl,
|
||||
required = false,
|
||||
class:
|
||||
klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control border border-line bg-surface hover:border-accent/50'
|
||||
icon: Icon = ImageIcon,
|
||||
gradient = false,
|
||||
class: klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control'
|
||||
}: Props = $props();
|
||||
|
||||
let preview = $state('');
|
||||
let fallbackFailed = $state(false);
|
||||
|
||||
const hasImage = $derived(preview !== '' || (!!fallbackUrl && !fallbackFailed));
|
||||
|
||||
function onInput(event: Event) {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (file) preview = URL.createObjectURL(file);
|
||||
@@ -26,7 +32,10 @@
|
||||
</script>
|
||||
|
||||
<label
|
||||
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {klass}"
|
||||
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {gradient
|
||||
? ''
|
||||
: 'border border-line bg-surface hover:border-accent/50'} {klass}"
|
||||
style={gradient && !hasImage ? 'background:var(--mf-cover-grad)' : undefined}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
@@ -48,6 +57,6 @@
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<ImageIcon class="h-12 w-12 text-muted" />
|
||||
<Icon class="h-9 w-9 {gradient ? 'text-white/70' : 'text-muted'}" strokeWidth={1.3} />
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
{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/60 focus:outline-none {Icon
|
||||
class="w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {Icon
|
||||
? 'pr-4 pl-11'
|
||||
: 'px-4'} {klass}"
|
||||
/>
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
{oncontextmenu}
|
||||
>
|
||||
{@render art(mediaCardArt)}
|
||||
<div class="mt-2.5 truncate text-fg transition-colors group-hover:text-accent-soft">{title}</div>
|
||||
<div class="mt-2.5 truncate text-sm font-medium text-fg">{title}</div>
|
||||
{#if subtitle}
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">{subtitle}</div>
|
||||
<div class="mt-0.5 truncate text-[12.5px] text-fg-3">{subtitle}</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
href?: string;
|
||||
target?: string;
|
||||
type?: 'button' | 'submit';
|
||||
reload?: boolean;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
@@ -15,6 +16,7 @@
|
||||
icon: Icon,
|
||||
label,
|
||||
href,
|
||||
target,
|
||||
type = 'button',
|
||||
reload = false,
|
||||
onclick,
|
||||
@@ -22,18 +24,25 @@
|
||||
}: Props = $props();
|
||||
|
||||
const classes = $derived(
|
||||
`flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg-2 transition hover:bg-hover ${klass}`
|
||||
`flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover ${klass}`
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a {href} data-sveltekit-reload={reload ? '' : undefined} {onclick} class={classes}>
|
||||
<Icon class="h-6 w-6 shrink-0" strokeWidth={2} />
|
||||
<a
|
||||
{href}
|
||||
{target}
|
||||
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
|
||||
data-sveltekit-reload={reload ? '' : undefined}
|
||||
{onclick}
|
||||
class={classes}
|
||||
>
|
||||
<Icon class="h-[18px] w-[18px] shrink-0" strokeWidth={1.8} />
|
||||
{label}
|
||||
</a>
|
||||
{:else}
|
||||
<button {type} {onclick} class={classes}>
|
||||
<Icon class="h-6 w-6 shrink-0" strokeWidth={2} />
|
||||
<Icon class="h-[18px] w-[18px] shrink-0" strokeWidth={1.8} />
|
||||
{label}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Cover from './Cover.svelte';
|
||||
import type { Mix } from '$lib/mixes';
|
||||
|
||||
interface Props {
|
||||
mix: Mix;
|
||||
featured?: boolean;
|
||||
banner?: boolean;
|
||||
class?: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
let { mix, featured = false, banner = false, class: klass = '', index = 0 }: Props = $props();
|
||||
|
||||
const fillCount = $derived(banner ? 40 : 12);
|
||||
|
||||
const tiles = $derived.by(() => {
|
||||
if (mix.items.length === 0) return [];
|
||||
const out: Mix['items'] = [];
|
||||
while (out.length < fillCount) out.push(...mix.items);
|
||||
return out.slice(0, fillCount);
|
||||
});
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/mixes/{mix.id}"
|
||||
class="group/tile animate-enter relative block overflow-hidden rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out [clip-path:inset(0_round_var(--radius-art))] ring-inset hover:shadow-art-lg focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none {klass}"
|
||||
style="animation-delay:{Math.min(index, 10) * 45}ms"
|
||||
>
|
||||
{#if tiles.length > 0}
|
||||
<div
|
||||
class="absolute inset-0 flex scale-105 transition duration-500 ease-out group-hover/tile:scale-110"
|
||||
>
|
||||
{#each tiles as item, i (i)}
|
||||
<Cover
|
||||
trackId={item.trackId ?? item.videoId ?? ''}
|
||||
src={item.thumbnailUrl}
|
||||
size="medium"
|
||||
class="aspect-square h-full shrink-0"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-linear-to-t from-scrim/85 via-scrim/65 to-scrim/40 {banner
|
||||
? 'sm:bg-linear-to-r'
|
||||
: ''}"
|
||||
></div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-scrim/25 opacity-0 transition-opacity duration-300"
|
||||
></div>
|
||||
<div
|
||||
class="relative flex h-full flex-col justify-end p-3 sm:p-4 {banner
|
||||
? 'sm:flex-row sm:items-center sm:justify-between sm:gap-4'
|
||||
: ''}"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-on-art {featured ? 'text-xl sm:text-2xl' : 'text-base'}">
|
||||
{mix.title}
|
||||
</div>
|
||||
{#if mix.subtitle}
|
||||
<div class="mt-0.5 truncate text-xs text-on-art/75 sm:text-sm">{mix.subtitle}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import MixArt from './MixArt.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { queueItemForTarget } from '$lib/tracks';
|
||||
import { mixItemTarget, type Mix } from '$lib/mixes';
|
||||
|
||||
interface Props {
|
||||
mix: Mix;
|
||||
index?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { mix, index = 0, class: klass = '' }: Props = $props();
|
||||
|
||||
function play(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
const items = mix.items.map((item) => queueItemForTarget(mixItemTarget(item)));
|
||||
player.playQueue(items, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/mixes/{mix.id}"
|
||||
class="group/tile animate-enter block cursor-pointer {klass}"
|
||||
style="animation-delay:{Math.min(index, 10) * 45}ms"
|
||||
>
|
||||
<div class="relative aspect-square overflow-hidden rounded-art shadow-art">
|
||||
<MixArt
|
||||
items={mix.items}
|
||||
class="h-full w-full scale-100 transition duration-300 group-hover/tile:scale-105"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent from-45% to-black/42"
|
||||
></div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={play}
|
||||
aria-label="Reproducir {mix.title}"
|
||||
class="absolute right-2.5 bottom-2.5 grid h-9.5 w-9.5 place-items-center rounded-full border border-white/12 bg-ink/76 text-fg opacity-0 backdrop-blur-[8px] transition-opacity duration-150 group-hover/tile:opacity-100"
|
||||
>
|
||||
<Play class="h-3.5 w-3.5" fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3.25 truncate text-[13px] font-semibold tracking-[-0.01em] text-fg">
|
||||
{mix.title}
|
||||
</div>
|
||||
{#if mix.subtitle}
|
||||
<div class="mt-1 truncate text-[11.5px] text-fg-3">{mix.subtitle}</div>
|
||||
{/if}
|
||||
</a>
|
||||
@@ -6,6 +6,7 @@
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
eyebrow?: string;
|
||||
title?: string;
|
||||
maxWidth?: string;
|
||||
panelClass?: string;
|
||||
@@ -15,9 +16,10 @@
|
||||
let {
|
||||
open,
|
||||
onClose,
|
||||
eyebrow,
|
||||
title,
|
||||
maxWidth = 'max-w-2xl',
|
||||
panelClass = 'border border-line bg-elevated',
|
||||
panelClass = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
@@ -74,16 +76,26 @@
|
||||
>
|
||||
<div
|
||||
bind:this={panel}
|
||||
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 shadow-2xl outline-none sm:p-6 {panelClass}"
|
||||
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 shadow-2xl backdrop-blur-[22px] transition-[background] duration-[600ms] outline-none sm:p-6 {panelClass}"
|
||||
style="background-color:var(--mf-panel-bg); background-image:var(--mf-modal-glow)"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if title}
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold tracking-tight text-fg">{title}</h2>
|
||||
<IconButton label="Cerrar" onclick={onClose}>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
{#if eyebrow}
|
||||
<p class="mb-2.5 text-xs font-semibold tracking-[0.1em] text-muted uppercase">
|
||||
{eyebrow}
|
||||
</p>
|
||||
{/if}
|
||||
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<IconButton label="Cerrar" onclick={onClose} class="shrink-0">
|
||||
<X class="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
@@ -5,19 +5,14 @@
|
||||
|
||||
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 }
|
||||
];
|
||||
const delays = [0, 0.3, 0.6];
|
||||
</script>
|
||||
|
||||
<div class="absolute inset-0 flex items-end justify-center gap-[2.5px] bg-scrim/45 pb-2">
|
||||
{#each bars as bar, i (i)}
|
||||
<div class="absolute inset-0 flex items-end justify-center gap-[3px] bg-scrim/45 pb-2.5">
|
||||
{#each delays as delay (delay)}
|
||||
<span
|
||||
class="w-[3px] origin-bottom rounded-full bg-on-art"
|
||||
style="height:14px; animation:equalize {bar.dur}s ease-in-out {bar.delay}s infinite; animation-play-state:{paused
|
||||
style="height:14px; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
||||
? 'paused'
|
||||
: 'running'}"
|
||||
></span>
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
let { class: klass = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="page-x pt-7 pb-8 sm:pt-11 sm:pb-10 {klass}">
|
||||
<section class="page-x pt-3 pb-8 sm:pt-4 sm:pb-10 {klass}">
|
||||
{@render children()}
|
||||
</section>
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}">
|
||||
<div class="min-w-0">
|
||||
<h1 class="font-display text-2xl font-semibold tracking-tight text-fg sm:text-4xl">{title}</h1>
|
||||
<h1 class="font-display text-[28px] font-semibold tracking-[-0.03em] text-fg sm:text-[32px]">
|
||||
{title}
|
||||
</h1>
|
||||
{#if subtitle}
|
||||
<p class="mt-2 text-sm text-fg-2 sm:text-base">{subtitle}</p>
|
||||
<p class="mt-2 text-[13px] text-fg-2">{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if actions}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<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 place-items-center rounded-full bg-accent-soft text-on-accent opacity-0 shadow-lg transition-opacity group-hover:opacity-100"
|
||||
class:!opacity-100={active}
|
||||
>
|
||||
{#if active && playing}
|
||||
<Pause class="h-5 w-5" fill="currentColor" />
|
||||
{:else}
|
||||
<Play class="h-5 w-5" fill="currentColor" />
|
||||
{/if}
|
||||
</span>
|
||||
@@ -5,16 +5,18 @@
|
||||
interface Props {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
trackCount: number;
|
||||
trackIds: (string | number)[];
|
||||
updatedAt: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
let { id, name, description, trackIds, updatedAt, index = 0 }: Props = $props();
|
||||
let { id, name, trackCount, trackIds, updatedAt, index = 0 }: Props = $props();
|
||||
|
||||
const subtitle = $derived(`${trackCount} ${trackCount === 1 ? 'canción' : 'canciones'}`);
|
||||
</script>
|
||||
|
||||
<MediaCard href="/playlists/{id}" title={name} subtitle={description} {index}>
|
||||
<MediaCard href="/playlists/{id}" title={name} {subtitle} {index}>
|
||||
{#snippet art(artClass)}
|
||||
<PlaylistArt playlistId={id} {trackIds} version={updatedAt} class={artClass} />
|
||||
{/snippet}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import ImageDropzone from './ImageDropzone.svelte';
|
||||
import Button from './Button.svelte';
|
||||
import Field from './Field.svelte';
|
||||
@@ -8,15 +9,20 @@
|
||||
import Textarea from './Textarea.svelte';
|
||||
import Alert from './Alert.svelte';
|
||||
|
||||
const NAME_MAX_LENGTH = 60;
|
||||
const DESCRIPTION_MAX_LENGTH = 300;
|
||||
|
||||
interface Props {
|
||||
action: string;
|
||||
initialName?: string;
|
||||
initialDescription?: string;
|
||||
initialVisibility?: 'private' | 'public';
|
||||
namePlaceholder?: string;
|
||||
coverFallbackUrl?: string;
|
||||
formMessage?: string;
|
||||
submitLabel: string;
|
||||
submittingLabel: string;
|
||||
helperText?: string;
|
||||
onCancel: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
@@ -25,11 +31,13 @@
|
||||
action,
|
||||
initialName = '',
|
||||
initialDescription = '',
|
||||
namePlaceholder = '',
|
||||
initialVisibility = 'private',
|
||||
namePlaceholder = 'Playlist sin título',
|
||||
coverFallbackUrl,
|
||||
formMessage,
|
||||
submitLabel,
|
||||
submittingLabel,
|
||||
helperText,
|
||||
onCancel,
|
||||
onSuccess
|
||||
}: Props = $props();
|
||||
@@ -37,6 +45,7 @@
|
||||
let name = $state(untrack(() => initialName));
|
||||
let description = $state(untrack(() => initialDescription));
|
||||
let submitting = $state(false);
|
||||
let visibility = $state(untrack(() => initialVisibility));
|
||||
</script>
|
||||
|
||||
<form
|
||||
@@ -48,49 +57,105 @@
|
||||
return async ({ update, result }) => {
|
||||
await update({ reset: false });
|
||||
submitting = false;
|
||||
if (result.type === 'success') onSuccess?.();
|
||||
if (result.type === 'success' || result.type === 'redirect') onSuccess?.();
|
||||
};
|
||||
}}
|
||||
class="mt-4 w-full max-w-4xl space-y-4"
|
||||
class="mt-5 w-full max-w-4xl space-y-5"
|
||||
>
|
||||
<input type="hidden" name="visibility" value={visibility} />
|
||||
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
|
||||
<Field label="Portada" optional>
|
||||
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} />
|
||||
<Field>
|
||||
<ImageDropzone
|
||||
name="cover"
|
||||
fallbackUrl={coverFallbackUrl}
|
||||
icon={Music}
|
||||
gradient
|
||||
class="h-36 w-36 rounded-[16px]"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-4">
|
||||
<Field label="Nombre" for="playlist-name">
|
||||
{#snippet hint()}
|
||||
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
|
||||
{/snippet}
|
||||
<Input
|
||||
id="playlist-name"
|
||||
name="name"
|
||||
maxlength={100}
|
||||
maxlength={NAME_MAX_LENGTH}
|
||||
required
|
||||
bind:value={name}
|
||||
placeholder={namePlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Descripción" for="playlist-description" optional class="min-h-0 flex-1">
|
||||
<Field label="Descripción" for="playlist-description" class="min-h-0 flex-1">
|
||||
<Textarea
|
||||
id="playlist-description"
|
||||
name="description"
|
||||
maxlength={300}
|
||||
maxlength={DESCRIPTION_MAX_LENGTH}
|
||||
bind:value={description}
|
||||
placeholder="¿De qué va esta lista?"
|
||||
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
|
||||
class="flex-1"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-[14px] bg-surface px-4 py-3.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-fg-2">Visibilidad</div>
|
||||
<div class="mt-0.5 text-xs text-muted">
|
||||
{visibility === 'private'
|
||||
? 'Solo tú la ves en tu biblioteca'
|
||||
: 'Cualquiera con el enlace puede verla'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1">
|
||||
<div
|
||||
class="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full bg-fg transition-transform duration-300 ease-out {visibility ===
|
||||
'public'
|
||||
? 'translate-x-full'
|
||||
: ''}"
|
||||
></div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (visibility = 'private')}
|
||||
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-semibold transition-colors {visibility ===
|
||||
'private'
|
||||
? 'text-ink'
|
||||
: 'text-muted hover:text-fg-2'}"
|
||||
>
|
||||
Privada
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (visibility = 'public')}
|
||||
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-semibold transition-colors {visibility ===
|
||||
'public'
|
||||
? 'text-ink'
|
||||
: 'text-muted hover:text-fg-2'}"
|
||||
>
|
||||
Pública
|
||||
</button>
|
||||
</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 class="flex items-center justify-between gap-4">
|
||||
{#if helperText}
|
||||
<p class="text-sm text-muted">{helperText}</p>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
|
||||
<Button type="submit" disabled={submitting || name.trim() === ''}>
|
||||
{submitting ? submittingLabel : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,24 +3,30 @@
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
class?: string;
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let { title, class: klass = '', actions }: Props = $props();
|
||||
let { title, subtitle, class: klass = '', actions }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#snippet heading()}
|
||||
<h2 class="font-display text-[17.5px] font-semibold tracking-[-0.025em] text-fg">
|
||||
{title}
|
||||
</h2>
|
||||
{#if subtitle}
|
||||
<p class="mt-1.25 text-xs text-fg-3">{subtitle}</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if actions}
|
||||
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-5 {klass}">
|
||||
<h2 class="font-display text-xl font-medium tracking-tight text-fg sm:text-[1.75rem]">
|
||||
{title}
|
||||
</h2>
|
||||
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-4.5 {klass}">
|
||||
<div>{@render heading()}</div>
|
||||
{@render actions()}
|
||||
</div>
|
||||
{:else}
|
||||
<h2
|
||||
class="mb-4 font-display text-xl font-medium tracking-tight text-fg sm:mb-5 sm:text-[1.75rem] {klass}"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div class="mb-4 sm:mb-4.5 {klass}">
|
||||
{@render heading()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
const paddings = { sm: 'p-4', md: 'p-5 sm:p-6', lg: 'p-6 sm:p-12' };
|
||||
</script>
|
||||
|
||||
<div class="rounded-panel border border-line bg-surface transition {paddings[padding]} {klass}">
|
||||
<div class="rounded-panel bg-surface transition {paddings[padding]} {klass}">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -24,5 +24,5 @@
|
||||
{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}"
|
||||
class="min-h-0 w-full resize-none rounded-control bg-surface px-4 py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {klass}"
|
||||
></textarea>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import NowPlaying from './NowPlaying.svelte';
|
||||
import EqBars from './EqBars.svelte';
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
@@ -16,10 +16,10 @@
|
||||
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"
|
||||
class="relative grid h-8 w-8 place-items-center rounded text-xs text-muted tabular-nums"
|
||||
>
|
||||
{#if active}
|
||||
<NowPlaying paused={!playing} />
|
||||
<EqBars 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" />
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
let { class: klass = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="truncate text-center text-sm text-muted tabular-nums {klass}">
|
||||
<span class="truncate text-center text-[12.5px] text-muted tabular-nums {klass}">
|
||||
{@render children()}
|
||||
</span>
|
||||
|
||||
@@ -4,19 +4,22 @@
|
||||
import { trackTableKey, type TrackTableContext } from './TrackTable.svelte';
|
||||
|
||||
interface Props {
|
||||
active?: boolean;
|
||||
class?: string;
|
||||
oncontextmenu?: (event: MouseEvent) => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { class: klass = '', oncontextmenu, children }: Props = $props();
|
||||
let { active = false, class: klass = '', oncontextmenu, children }: Props = $props();
|
||||
|
||||
const table = getContext<TrackTableContext>(trackTableKey);
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group grid track-grid items-center gap-3 rounded-control py-2.5 pr-4 pl-2 transition hover:bg-surface sm:gap-8 sm:pr-6 sm:pl-3 {klass}"
|
||||
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-8 sm:pr-6 sm:pl-3 {active
|
||||
? 'bg-accent-tint'
|
||||
: 'hover:bg-white/[3.2%]'} {klass}"
|
||||
style="--mf-track-cols:{table.columns}; --mf-track-cols-mobile:{table.columnsMobile}"
|
||||
{oncontextmenu}
|
||||
>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
const columns = $derived(
|
||||
[
|
||||
index ? '32px' : '',
|
||||
'1fr',
|
||||
'minmax(180px,1fr)',
|
||||
...meta.map((column) => column.width),
|
||||
'64px',
|
||||
action ? '36px' : ''
|
||||
@@ -39,7 +39,7 @@
|
||||
);
|
||||
|
||||
const columnsMobile = $derived(
|
||||
[index ? '28px' : '', '1fr', '52px', action ? '32px' : '']
|
||||
[index ? '28px' : '', 'minmax(120px,1fr)', '52px', action ? '32px' : '']
|
||||
.filter((width) => width !== '')
|
||||
.join(' ')
|
||||
);
|
||||
@@ -54,9 +54,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={klass}>
|
||||
<div class="overflow-x-auto {klass}">
|
||||
<div
|
||||
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-2 pl-2 text-sm font-medium tracking-wide text-muted uppercase sm:gap-8 sm:pr-6 sm:pl-3"
|
||||
class="grid track-grid items-center gap-3 border-b border-white/5 pr-4 pb-3 pl-2 text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase sm:gap-8 sm:pr-6 sm:pl-3"
|
||||
style="--mf-track-cols:{columns}; --mf-track-cols-mobile:{columnsMobile}"
|
||||
>
|
||||
{#if index}
|
||||
@@ -71,7 +71,7 @@
|
||||
<span></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 flex flex-col">
|
||||
<div class="mt-1 flex flex-col gap-0.5">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import Cover from './Cover.svelte';
|
||||
import ExplicitBadge from './ExplicitBadge.svelte';
|
||||
import ArtistLink from './ArtistLink.svelte';
|
||||
|
||||
interface Props {
|
||||
trackId: string | number;
|
||||
title: string;
|
||||
artist?: string | null;
|
||||
ownerUserId?: string | number | null;
|
||||
coverSrc?: string | null;
|
||||
coverSize?: string;
|
||||
explicit?: boolean;
|
||||
active?: boolean;
|
||||
titleClass?: string;
|
||||
onClick: () => void;
|
||||
overlay?: Snippet;
|
||||
@@ -20,17 +23,31 @@
|
||||
trackId,
|
||||
title,
|
||||
artist,
|
||||
ownerUserId,
|
||||
coverSrc,
|
||||
coverSize = 'h-[42px] w-[42px]',
|
||||
explicit = false,
|
||||
active = false,
|
||||
titleClass = '',
|
||||
onClick,
|
||||
overlay,
|
||||
badge
|
||||
}: Props = $props();
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
</script>
|
||||
|
||||
<button type="button" onclick={onClick} class="flex min-w-0 items-center gap-3.5 text-left">
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={onClick}
|
||||
onkeydown={onKeydown}
|
||||
class="flex min-w-0 items-center gap-3.5 text-left"
|
||||
>
|
||||
<Cover
|
||||
{trackId}
|
||||
src={coverSrc}
|
||||
@@ -45,11 +62,13 @@
|
||||
{#if explicit}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="min-w-0 truncate text-fg {titleClass}">{title}</span>
|
||||
<span
|
||||
class="min-w-0 truncate text-sm font-medium {active
|
||||
? 'text-accent-soft'
|
||||
: 'text-fg'} {titleClass}">{title}</span
|
||||
>
|
||||
{@render badge?.()}
|
||||
</div>
|
||||
{#if artist}
|
||||
<div class="truncate text-left text-sm text-fg-3">{artist}</div>
|
||||
{/if}
|
||||
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-[12.5px] text-fg-3" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user