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