Animate library tabs, rows, editor track lists, status badges and dropzone
Tabs get a sliding indicator that persists across library routes, grid rows flip and collapse on removal, the selection bar and filters crossfade, album tracks move between lists with a crossfade, processing badges pulse and the dropzone scales while dragging.
This commit is contained in:
@@ -15,6 +15,7 @@ const ARBITRARY_WHITELIST = [
|
||||
/^my-\[\d+d?vh\]$/, // +error.svelte: centrado vertical de página de error
|
||||
/^py-\[\d+d?vh\]$/, // Explorar: centrado vertical de "sin resultados"
|
||||
/^transition-\[filter\]$/, // Explorar: genre-tile hover
|
||||
/^transition-\[transform,width\]$/, // Tabs: indicador deslizante
|
||||
/^bg-\[image:var\(--mf-[\w-]+\)\]$/
|
||||
];
|
||||
|
||||
|
||||
@@ -13,4 +13,9 @@
|
||||
const info = $derived(TRACK_STATUS[status]);
|
||||
</script>
|
||||
|
||||
<StatusBadge label={info.label} tone={info.tone} class={klass} />
|
||||
<StatusBadge
|
||||
label={info.label}
|
||||
tone={info.tone}
|
||||
pulse={status === 'Processing' || status === 'Pending'}
|
||||
class={klass}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import type { LibraryTrack } from '$lib/types';
|
||||
import { plural } from '$lib/utils/format';
|
||||
import { transferBetweenLists } from '$lib/utils/transitions';
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||
import TrackPickList from './TrackPickList.svelte';
|
||||
|
||||
@@ -14,6 +15,8 @@
|
||||
|
||||
let { tracks, memberIds }: Props = $props();
|
||||
|
||||
const [send, receive] = transferBetweenLists();
|
||||
|
||||
let currentIds = $state<string[]>(untrack(() => [...memberIds]));
|
||||
|
||||
const initialIds = untrack(() => new Set(memberIds));
|
||||
@@ -43,13 +46,13 @@
|
||||
|
||||
{#if available.length > 0}
|
||||
<h3 class="mt-6 mb-2 px-3 text-eyebrow text-fg-2">Añadir desde tu biblioteca</h3>
|
||||
<TrackPickList tracks={available} icon={Plus} onPick={add} />
|
||||
<TrackPickList tracks={available} icon={Plus} {send} {receive} onPick={add} />
|
||||
{/if}
|
||||
|
||||
<div class="mt-6">
|
||||
{#if inAlbum.length > 0}
|
||||
<h3 class="mb-2 px-3 text-eyebrow text-fg-2">En este álbum</h3>
|
||||
<TrackPickList tracks={inAlbum} icon={X} onPick={remove} />
|
||||
<TrackPickList tracks={inAlbum} icon={X} {send} {receive} onPick={remove} />
|
||||
{:else}
|
||||
<EmptyState
|
||||
title="Este álbum aún no tiene canciones"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<script lang="ts" module>
|
||||
const FADE = { duration: 200 };
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import SearchX from '@lucide/svelte/icons/search-x';
|
||||
@@ -64,25 +69,32 @@
|
||||
{/if}
|
||||
|
||||
{#if tracks.length > 0}
|
||||
{#if selectedIds.length > 0}
|
||||
<SelectionBar
|
||||
class="mt-4"
|
||||
label={plural(selectedIds.length, 'seleccionada', 'seleccionadas')}
|
||||
onClear={() => (selectedIds = [])}
|
||||
>
|
||||
<Button size="sm" variant="danger" onclick={deleteSelected}>Borrar</Button>
|
||||
</SelectionBar>
|
||||
{:else}
|
||||
<div class="mt-4 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<SearchInput
|
||||
bind:value={query}
|
||||
placeholder="Filtrar por título o artista"
|
||||
size="sm"
|
||||
class="w-full sm:max-w-search sm:flex-1"
|
||||
/>
|
||||
<ChipGroup label="Filtrar por estado" options={filterOptions} bind:value={filter} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-4 grid">
|
||||
{#if selectedIds.length > 0}
|
||||
<div class="col-start-1 row-start-1" in:fade={FADE} out:fade={FADE}>
|
||||
<SelectionBar
|
||||
label={plural(selectedIds.length, 'seleccionada', 'seleccionadas')}
|
||||
onClear={() => (selectedIds = [])}
|
||||
>
|
||||
<Button size="sm" variant="danger" onclick={deleteSelected}>Borrar</Button>
|
||||
</SelectionBar>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="col-start-1 row-start-1 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3"
|
||||
in:fade={FADE}
|
||||
out:fade={FADE}
|
||||
>
|
||||
<SearchInput
|
||||
bind:value={query}
|
||||
placeholder="Filtrar por título o artista"
|
||||
size="sm"
|
||||
class="w-full sm:max-w-search sm:flex-1"
|
||||
/>
|
||||
<ChipGroup label="Filtrar por estado" options={filterOptions} bind:value={filter} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if visible.length > 0}
|
||||
<ManageTrackList
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
columns={COLUMNS}
|
||||
rows={tracks}
|
||||
rowKey={(track) => track.id}
|
||||
stagger
|
||||
rowClass={(track) => (player.currentId === track.id ? 'surface-active' : 'hover:bg-hover')}
|
||||
>
|
||||
{#snippet head()}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { LucideIcon } from '@lucide/svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { expoOut } from 'svelte/easing';
|
||||
import type { LibraryTrack } from '$lib/types';
|
||||
import type { transferBetweenLists } from '$lib/utils/transitions';
|
||||
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
||||
|
||||
type Transfer = ReturnType<typeof transferBetweenLists>;
|
||||
|
||||
interface Props {
|
||||
tracks: LibraryTrack[];
|
||||
icon: LucideIcon;
|
||||
send: Transfer[0];
|
||||
receive: Transfer[1];
|
||||
onPick: (track: LibraryTrack) => void;
|
||||
}
|
||||
|
||||
let { tracks, icon: Icon, onPick }: Props = $props();
|
||||
let { tracks, icon: Icon, send, receive, onPick }: Props = $props();
|
||||
|
||||
function subtitle(track: LibraryTrack): string {
|
||||
const album = track.albumTitle ? `en ${track.albumTitle}` : 'Sin álbum';
|
||||
@@ -19,7 +26,12 @@
|
||||
|
||||
<div class="flex flex-col gap-px" role="list">
|
||||
{#each tracks as track (track.id)}
|
||||
<div role="listitem">
|
||||
<div
|
||||
role="listitem"
|
||||
animate:flip={{ duration: 200, easing: expoOut }}
|
||||
in:receive|global={{ key: track.id }}
|
||||
out:send|global={{ key: track.id }}
|
||||
>
|
||||
<ListRow
|
||||
size="xs"
|
||||
trackId={track.id}
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
ondragleave={onLeave}
|
||||
ondrop={onDrop}
|
||||
class="flex min-h-dropzone flex-col items-center justify-center rounded-panel border border-dashed p-6 text-center transition sm:p-10 {dragging
|
||||
? 'border-accent/60 bg-accent-tint'
|
||||
? 'scale-101 border-accent/60 bg-accent-tint'
|
||||
: 'border-line-strong bg-surface'} {klass}"
|
||||
>
|
||||
<span class="grid size-14 place-items-center rounded-full bg-surface-2 text-fg-2">
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
<script lang="ts" generics="T">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { expoOut } from 'svelte/easing';
|
||||
import { collapse } from '$lib/utils/transitions';
|
||||
import { gridColumns, type GridColumn } from '$lib/utils/gridColumns';
|
||||
|
||||
interface Props {
|
||||
@@ -15,6 +18,7 @@
|
||||
rowClass?: (row: T, index: number) => string;
|
||||
oncontextmenu?: (event: MouseEvent, row: T, index: number) => void;
|
||||
gap?: 'md' | 'lg';
|
||||
stagger?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -27,6 +31,7 @@
|
||||
rowClass,
|
||||
oncontextmenu,
|
||||
gap = 'md',
|
||||
stagger = false,
|
||||
class: klass = ''
|
||||
}: Props = $props();
|
||||
|
||||
@@ -60,10 +65,12 @@
|
||||
{#each rows as row, i (rowKey(row))}
|
||||
<div
|
||||
role="listitem"
|
||||
animate:flip={{ duration: 200, easing: expoOut }}
|
||||
out:collapse
|
||||
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:pr-6 sm:pl-3 {GAP[
|
||||
gap
|
||||
]} {rowClass?.(row, i) ?? ''}"
|
||||
{style}
|
||||
]} {stagger ? 'animate-enter' : ''} {rowClass?.(row, i) ?? ''}"
|
||||
style="{style}{stagger ? `; --i:${i}` : ''}"
|
||||
oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, row, i) : undefined}
|
||||
>
|
||||
{#each columns as column (column.id)}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
interface Props {
|
||||
label: string;
|
||||
tone?: 'success' | 'neutral' | 'danger';
|
||||
pulse?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { label, tone = 'neutral', class: klass = '' }: Props = $props();
|
||||
let { label, tone = 'neutral', pulse = false, class: klass = '' }: Props = $props();
|
||||
|
||||
const TONE = {
|
||||
success: 'bg-accent-tint text-accent-soft',
|
||||
@@ -15,7 +16,7 @@
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap {TONE[
|
||||
tone
|
||||
]} {klass}">{label}</span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap {pulse
|
||||
? 'animate-pulse'
|
||||
: ''} {TONE[tone]} {klass}">{label}</span
|
||||
>
|
||||
|
||||
@@ -12,16 +12,43 @@
|
||||
}
|
||||
|
||||
let { label, tabs, class: klass = '' }: Props = $props();
|
||||
|
||||
let links = $state<Record<string, HTMLAnchorElement | undefined>>({});
|
||||
let indicator = $state({ x: 0, width: 0 });
|
||||
let measured = $state(false);
|
||||
let animated = $state(false);
|
||||
|
||||
const activeHref = $derived(tabs.find((tab) => tab.active)?.href);
|
||||
|
||||
$effect(() => {
|
||||
const link = activeHref ? links[activeHref] : undefined;
|
||||
if (!link) return;
|
||||
indicator = { x: link.offsetLeft, width: link.offsetWidth };
|
||||
measured = true;
|
||||
if (!animated) requestAnimationFrame(() => (animated = true));
|
||||
});
|
||||
</script>
|
||||
|
||||
<nav aria-label={label} class="flex gap-2 {klass}">
|
||||
<nav
|
||||
aria-label={label}
|
||||
class="relative inline-flex max-w-full rounded-full bg-surface-2 p-1 {klass}"
|
||||
>
|
||||
{#if measured}
|
||||
<div
|
||||
class="absolute inset-y-1 left-0 rounded-full bg-fg ease-snappy {animated
|
||||
? 'transition-[transform,width] duration-200'
|
||||
: ''}"
|
||||
style="width:{indicator.width}px; transform:translateX({indicator.x}px)"
|
||||
></div>
|
||||
{/if}
|
||||
{#each tabs as tab (tab.href)}
|
||||
<a
|
||||
bind:this={links[tab.href]}
|
||||
href={tab.href}
|
||||
aria-current={tab.active ? 'page' : undefined}
|
||||
class="rounded-full px-4 py-1.5 text-xs font-medium focus-ring transition-colors {tab.active
|
||||
? 'bg-fg text-ink'
|
||||
: 'bg-surface-2 text-fg-2 hover:text-fg'}"
|
||||
class="relative z-(--z-raised) rounded-full px-4 py-1.5 text-xs font-medium focus-ring transition-colors {tab.active
|
||||
? 'text-ink'
|
||||
: 'text-fg-2 hover:text-fg'}"
|
||||
>
|
||||
{tab.label}
|
||||
</a>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expoOut } from 'svelte/easing';
|
||||
import type { TransitionConfig } from 'svelte/transition';
|
||||
import { crossfade, type TransitionConfig } from 'svelte/transition';
|
||||
|
||||
export function pop(
|
||||
node: Element,
|
||||
@@ -12,3 +12,23 @@ export function pop(
|
||||
`opacity: ${t}; transform: scale(${0.98 + 0.02 * t}) translateY(${0.25 * (1 - t)}rem);`
|
||||
};
|
||||
}
|
||||
|
||||
export function collapse(
|
||||
node: Element,
|
||||
{ duration = 200 }: { duration?: number } = {}
|
||||
): TransitionConfig {
|
||||
const height = node.getBoundingClientRect().height;
|
||||
const style = getComputedStyle(node);
|
||||
const paddingTop = parseFloat(style.paddingTop);
|
||||
const paddingBottom = parseFloat(style.paddingBottom);
|
||||
return {
|
||||
duration,
|
||||
easing: expoOut,
|
||||
css: (t) =>
|
||||
`overflow: hidden; opacity: ${t}; height: ${height * t}px; padding-top: ${paddingTop * t}px; padding-bottom: ${paddingBottom * t}px;`
|
||||
};
|
||||
}
|
||||
|
||||
export function transferBetweenLists() {
|
||||
return crossfade({ duration: 200, easing: expoOut });
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
|
||||
let scroller = $state<HTMLElement>();
|
||||
|
||||
const transitionKey = $derived(
|
||||
page.url.pathname === '/library' || page.url.pathname.startsWith('/library/')
|
||||
? '/library'
|
||||
: page.url.pathname
|
||||
);
|
||||
|
||||
trackNavigation();
|
||||
restoreScroll(() => scroller);
|
||||
|
||||
@@ -81,7 +87,7 @@
|
||||
<MobileHeader user={data.user} accountUrl={data.accountUrl} />
|
||||
<TopBar user={data.user} accountUrl={data.accountUrl} />
|
||||
<main class="flex-1">
|
||||
{#key page.url.pathname}
|
||||
{#key transitionKey}
|
||||
<div class="animate-fade">{@render children()}</div>
|
||||
{/key}
|
||||
</main>
|
||||
|
||||
@@ -47,5 +47,7 @@
|
||||
<Page>
|
||||
<PageHeader title="Gestionar música" description={current.description} />
|
||||
<Tabs label="Secciones de la biblioteca" {tabs} />
|
||||
{@render children()}
|
||||
{#key page.url.pathname}
|
||||
<div class="animate-fade">{@render children()}</div>
|
||||
{/key}
|
||||
</Page>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
let { data, form } = $props();
|
||||
</script>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="animate-enter mt-4">
|
||||
{#key data.album.id}
|
||||
<LibraryAlbumEditor
|
||||
album={data.album}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
let { data, form } = $props();
|
||||
</script>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="animate-enter mt-4">
|
||||
<LibraryAlbumEditor
|
||||
album={null}
|
||||
artist={page.data.user?.name ?? ''}
|
||||
|
||||
Reference in New Issue
Block a user