Categorize components/ui into primitives, overlay, media, forms and layout

Splits the flat 38-component ui/ folder into primitives/ (generic
controls), overlay/ (menus/modals), media/ (music-domain display
components), forms/ (composite entity forms) and layout/ (page
scaffolding), and updates every import path accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 14:16:25 +02:00
co-authored by Claude Sonnet 5
parent 8a76fbb78e
commit 705f23c78f
61 changed files with 131 additions and 131 deletions
@@ -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="block max-w-full truncate hover:text-fg-2 hover:underline {klass}"
>
{name}
</a>
{:else}
<span class="block truncate {klass}">{name}</span>
{/if}
{/if}
@@ -0,0 +1,133 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'hero' | 'fill';
interface Props {
src?: string | null;
trackIds?: (string | number)[];
size: Size;
shape?: 'auto' | 'round';
imageSize?: 'small' | 'medium' | 'large';
fallback?: 'music' | 'gradient' | 'none';
alt?: string;
class?: string;
children?: Snippet;
}
let {
src,
trackIds = [],
size,
shape = 'auto',
imageSize,
fallback = 'music',
alt = '',
class: klass = '',
children
}: Props = $props();
const DIMENSION: Record<Size, string> = {
xs: 'size-cover-xs',
sm: 'size-cover-sm',
md: 'size-cover-md',
lg: 'size-cover-lg',
xl: 'size-cover-xl',
'2xl': 'size-cover-2xl',
hero: 'size-cover-hero sm:size-cover-hero-sm',
fill: 'aspect-square w-full'
};
const RADIUS: Record<Size, string> = {
xs: 'rounded-thumb',
sm: 'rounded-art',
md: 'rounded-art',
lg: 'rounded-art',
xl: 'rounded-art',
'2xl': 'rounded-art-lg',
hero: 'rounded-art-lg',
fill: ''
};
const AUTO_IMAGE_SIZE: Record<Size, 'small' | 'medium' | 'large'> = {
xs: 'small',
sm: 'small',
md: 'medium',
lg: 'medium',
xl: 'medium',
'2xl': 'large',
hero: 'large',
fill: 'large'
};
const resolvedImageSize = $derived(imageSize ?? AUTO_IMAGE_SIZE[size]);
const radius = $derived(shape === 'round' ? 'rounded-full' : RADIUS[size]);
let srcFailed = $state(false);
$effect(() => {
src;
srcFailed = false;
});
let singleFailed = $state(false);
$effect(() => {
trackIds[0];
singleFailed = false;
});
const showSrc = $derived(!!src && !srcFailed);
const showMosaic = $derived(!showSrc && trackIds.length >= 4);
const singleId = $derived(!showSrc && !showMosaic && trackIds.length > 0 ? trackIds[0] : null);
const showSingle = $derived(singleId !== null && !singleFailed);
</script>
<div class="relative overflow-hidden {DIMENSION[size]} {radius} {klass}">
{#if showSrc}
<img
{src}
{alt}
loading="lazy"
decoding="async"
fetchpriority="low"
onerror={() => (srcFailed = true)}
class="h-full w-full object-cover"
/>
{:else if showMosaic}
<div class="grid h-full w-full grid-cols-2 grid-rows-2">
{#each trackIds.slice(0, 4) as id (id)}
<img
src="/api/tracks/{id}/cover?size=small"
alt=""
loading="lazy"
decoding="async"
fetchpriority="low"
onerror={(e) => ((e.currentTarget as HTMLImageElement).style.visibility = 'hidden')}
class="h-full w-full object-cover"
/>
{/each}
</div>
{:else if showSingle}
<img
src="/api/tracks/{singleId}/cover?size={resolvedImageSize}"
{alt}
loading="lazy"
decoding="async"
fetchpriority="low"
onerror={() => (singleFailed = true)}
class="h-full w-full object-cover"
/>
{:else if fallback === 'music'}
<div class="grid h-full w-full place-items-center bg-surface">
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-3">
<path
d="M19.952 1.651a.75.75 0 0 1 .298.599V16.303a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.401-4.909l2.311-.66a1.5 1.5 0 0 0 1.088-1.442V6.994l-9 2.572v9.737a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.402-4.909l2.312-.66a1.5 1.5 0 0 0 1.088-1.442V5.25a.75.75 0 0 1 .544-.721l10.5-3a.75.75 0 0 1 .754.122Z"
/>
</svg>
</div>
{:else if fallback === 'gradient'}
<div class="h-full w-full bg-[image:var(--mf-cover-grad)]"></div>
{:else}
<div class="h-full w-full bg-surface"></div>
{/if}
{@render children?.()}
</div>
@@ -0,0 +1,60 @@
<script lang="ts">
type Size = 'sm' | 'md' | 'lg' | 'hero';
interface Props {
name: string;
src?: string | null;
size: Size;
class?: string;
}
let { name, src, size, class: klass = '' }: Props = $props();
const DIMENSION: Record<Size, string> = {
sm: 'size-cover-xs',
md: 'size-cover-lg',
lg: 'size-cover-xl',
hero: 'size-cover-hero'
};
const TEXT: Record<Size, string> = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-xl',
hero: 'text-3xl'
};
const initials = $derived(
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
);
let failed = $state(false);
$effect(() => {
src;
failed = false;
});
</script>
<div
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-surface-2 {DIMENSION[
size
]} {klass}"
>
{#if src && !failed}
<img
{src}
alt=""
loading="lazy"
decoding="async"
onerror={() => (failed = true)}
class="absolute inset-0 h-full w-full object-cover"
/>
{:else}
<span class="font-semibold text-fg uppercase {TEXT[size]}">{initials}</span>
{/if}
</div>
@@ -0,0 +1,43 @@
<script lang="ts">
interface Props {
size?: number;
barWidth?: number;
paused?: boolean;
overlay?: boolean;
class?: string;
}
let {
size = 13,
barWidth = 2,
paused = false,
overlay = false,
class: klass = ''
}: Props = $props();
const delays = [0, 0.3, 0.6];
</script>
{#if overlay}
<div class="absolute inset-0 flex items-end justify-center gap-1 bg-scrim/45 pb-2.5 {klass}">
{#each delays as delay (delay)}
<span
class="h-3.5 w-1 origin-bottom rounded-full bg-on-art"
style="animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
{/each}
</div>
{:else}
<div class="flex items-end justify-center gap-0.5 {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>
{/if}
@@ -0,0 +1,13 @@
<script lang="ts">
interface Props {
class?: string;
}
let { class: klass = '' }: Props = $props();
</script>
<span
class="grid size-4 shrink-0 place-items-center rounded-tag bg-fg-3 text-xs leading-4 font-bold text-bg {klass}"
title="Contenido explícito"
aria-label="Contenido explícito">E</span
>
@@ -0,0 +1,90 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import MediaIdentity from './MediaIdentity.svelte';
import { pressable } from '$lib/actions/pressable';
interface Props {
href?: string;
onclick?: () => void;
oncontextmenu?: (event: MouseEvent) => void;
active?: boolean;
size?: 'xs' | 'sm' | 'lg';
variant?: 'row' | 'card';
title: string;
subtitle?: string | null;
subtitleHref?: string | number | null;
explicit?: boolean;
trackId?: string | number;
trackIds?: (string | number)[];
coverSrc?: string | null;
art?: Snippet;
overlay?: Snippet;
leading?: Snippet;
trailing?: Snippet;
class?: string;
style?: string;
}
let {
href,
onclick,
oncontextmenu,
active = false,
size = 'sm',
variant = 'row',
title,
subtitle,
subtitleHref,
explicit = false,
trackId,
trackIds,
coverSrc,
art,
overlay,
leading,
trailing,
class: klass = '',
style
}: Props = $props();
const classes = $derived(
`flex items-center gap-3 text-left transition-colors ${
variant === 'card' ? 'rounded-art p-2 pr-3.5' : 'rounded-control p-2'
} ${active ? 'bg-accent-tint' : 'hover:bg-hover'} ${klass}`
);
</script>
{#snippet content()}
{@render leading?.()}
<MediaIdentity
{title}
artist={subtitle}
ownerUserId={subtitleHref}
{trackId}
{trackIds}
{coverSrc}
{size}
{explicit}
{active}
{art}
{overlay}
/>
{@render trailing?.()}
{/snippet}
{#if href}
<a {href} {oncontextmenu} class={classes} {style}>
{@render content()}
</a>
{:else}
<div
role="button"
tabindex="0"
use:pressable={onclick ?? (() => {})}
{oncontextmenu}
class={classes}
{style}
>
{@render content()}
</div>
{/if}
@@ -0,0 +1,61 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Artwork from './Artwork.svelte';
import PlayButton from './PlayButton.svelte';
interface Props {
href: string;
title: string;
subtitle?: string | null;
src?: string | null;
trackIds?: (string | number)[];
onPlay?: (event: MouseEvent) => void;
art?: Snippet;
oncontextmenu?: (event: MouseEvent) => void;
index?: number;
}
let {
href,
title,
subtitle,
src,
trackIds = [],
onPlay,
art,
oncontextmenu,
index = 0
}: Props = $props();
</script>
<a {href} class="group animate-enter min-w-0 text-left" style="--i:{index}" {oncontextmenu}>
<div class="relative">
{#if art}
{@render art()}
{:else}
<Artwork
{src}
{trackIds}
size="fill"
alt={title}
class="rounded-art transition duration-300 group-hover:scale-105"
/>
{/if}
{#if onPlay}
<div
class="pointer-events-none absolute inset-0 rounded-art bg-linear-to-b from-transparent from-45% to-scrim/40"
></div>
<PlayButton
variant="glass"
size="sm"
onclick={onPlay}
label="Reproducir {title}"
class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity duration-150 group-hover:opacity-100"
/>
{/if}
</div>
<div class="mt-2.5 truncate text-sm text-fg">{title}</div>
{#if subtitle}
<div class="mt-0.5 truncate text-xs text-fg-3">{subtitle}</div>
{/if}
</a>
@@ -0,0 +1,68 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Artwork from './Artwork.svelte';
import ExplicitBadge from './ExplicitBadge.svelte';
import ArtistLink from './ArtistLink.svelte';
interface Props {
trackId?: string | number;
trackIds?: (string | number)[];
title: string;
artist?: string | null;
ownerUserId?: string | number | null;
coverSrc?: string | null;
size?: 'xs' | 'sm' | 'lg';
explicit?: boolean;
active?: boolean;
titleClass?: string;
class?: string;
art?: Snippet;
overlay?: Snippet;
badge?: Snippet;
}
let {
trackId,
trackIds,
title,
artist,
ownerUserId,
coverSrc,
size = 'sm',
explicit = false,
active = false,
titleClass = '',
class: klass = '',
art,
overlay,
badge
}: Props = $props();
</script>
<div class="flex min-w-0 flex-1 items-center gap-3.5 {klass}">
{#if art}
{@render art()}
{:else if trackId !== undefined || coverSrc || trackIds?.length}
<Artwork
trackIds={trackIds ?? (trackId !== undefined ? [trackId] : [])}
src={coverSrc}
{size}
alt={title}
class="shrink-0"
>
{@render overlay?.()}
</Artwork>
{/if}
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
{#if explicit}
<ExplicitBadge />
{/if}
<span class="min-w-0 truncate text-sm {active ? 'text-accent-soft' : 'text-fg'} {titleClass}"
>{title}</span
>
{@render badge?.()}
</div>
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-xs text-fg-3" />
</div>
</div>
@@ -0,0 +1,59 @@
<script lang="ts">
interface Props {
playing?: boolean;
size?: 'sm' | 'md' | 'lg';
variant?: 'strong' | 'glass';
label: string;
onclick?: (event: MouseEvent) => void;
as?: 'button' | 'span';
class?: string;
}
let {
playing = false,
size = 'md',
variant = 'strong',
label,
onclick,
as = 'button',
class: klass = ''
}: Props = $props();
const SIZE: Record<'sm' | 'md' | 'lg', string> = {
sm: 'size-9.5',
md: 'size-10',
lg: 'size-11.5'
};
const VARIANT: Record<'strong' | 'glass', string> = {
strong: 'bg-cta-strong text-ink hover:brightness-95',
glass: 'border border-on-art/12 bg-ink/76 text-fg backdrop-blur'
};
const classes = $derived(
`grid shrink-0 place-items-center rounded-full transition duration-150 ${SIZE[size]} ${VARIANT[variant]} ${klass}`
);
</script>
{#snippet icon()}
{#if playing}
<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="15" height="15" viewBox="0 0 15 15" fill="currentColor">
<path d="M3 1.5v12l10-6z" />
</svg>
{/if}
{/snippet}
{#if as === 'span'}
<span aria-hidden="true" class={classes}>
{@render icon()}
</span>
{:else}
<button type="button" {onclick} aria-label={label} class={classes}>
{@render icon()}
</button>
{/if}
@@ -0,0 +1,200 @@
<script lang="ts" module>
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
export interface TrackListTrack {
id: string | number;
title: string;
artist?: string | null;
ownerUserId?: string | number | null;
isExplicit?: boolean;
explicit?: boolean;
duration?: number | string;
listensCount?: number | string;
addedAt?: string | number;
uploadedAt?: string | number;
}
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
added: { label: 'Añadida', width: '124px' },
uploaded: { label: 'Subida', width: '124px' },
plays: { label: 'Escuchas', width: '104px' }
};
const INDEX_W = { desktop: '32px', mobile: '28px' };
const TITLE_W = { desktop: 'minmax(180px,1fr)', mobile: 'minmax(120px,1fr)' };
const DURATION_W = { desktop: '104px', mobile: '52px' };
const ACTION_W = { desktop: '36px', mobile: '32px' };
</script>
<script lang="ts">
import { enhance } from '$app/forms';
import type { LucideIcon } from '@lucide/svelte';
import Hash from '@lucide/svelte/icons/hash';
import Play from '@lucide/svelte/icons/play';
import { player } from '$lib/player/player.svelte';
import { fmtTime, fmtDate } from '$lib/utils/format';
import { pressable } from '$lib/actions/pressable';
import MediaIdentity from './MediaIdentity.svelte';
import EqBars from './EqBars.svelte';
import IconButton from '../primitives/IconButton.svelte';
interface RowAction {
action?: string;
onclick?: (track: TrackListTrack) => void;
icon: LucideIcon;
label: string;
}
interface Props {
tracks: TrackListTrack[];
columns?: TrackListColumn[];
index?: boolean;
onPlay: (index: number) => void;
rowAction?: RowAction;
oncontextmenu?: (event: MouseEvent, track: TrackListTrack, index: number) => void;
class?: string;
}
let {
tracks,
columns = [],
index = true,
onPlay,
rowAction,
oncontextmenu,
class: klass = ''
}: Props = $props();
const gridColumns = $derived(
[
index ? INDEX_W.desktop : '',
TITLE_W.desktop,
...columns.map((c) => COLUMN_PRESET[c].width),
DURATION_W.desktop,
rowAction ? ACTION_W.desktop : ''
]
.filter(Boolean)
.join(' ')
);
const gridColumnsMobile = $derived(
[
index ? INDEX_W.mobile : '',
TITLE_W.mobile,
DURATION_W.mobile,
rowAction ? ACTION_W.mobile : ''
]
.filter(Boolean)
.join(' ')
);
</script>
<div class="overflow-x-auto {klass}">
<div
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-3 sm:gap-8 sm:pr-6 sm:pl-3"
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
>
{#if index}
<Hash size={16} strokeWidth={1.5} />
{/if}
<span>Título</span>
{#each columns as column (column)}
<span class="hidden text-center sm:block">{COLUMN_PRESET[column].label}</span>
{/each}
<span class="hidden text-center sm:block">Duración</span>
{#if rowAction}
<span></span>
{/if}
</div>
<div class="mt-1 flex flex-col gap-0.5">
{#each tracks as track, i (track.id)}
{@const active = player.current.id === track.id}
<div
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-hover'}"
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, track, i) : undefined}
>
{#if index}
<button
type="button"
onclick={() => onPlay(i)}
aria-label={active && player.playing ? 'Pausar' : 'Reproducir'}
class="relative grid size-8 place-items-center rounded-tag text-xs text-muted tabular-nums"
>
{#if active}
<EqBars paused={!player.playing} />
{:else}
<span class="group-hover:hidden">{i + 1}</span>
<Play class="hidden size-icon-sm text-fg group-hover:block" fill="currentColor" />
{/if}
</button>
{/if}
<div
role="button"
tabindex="0"
use:pressable={() => onPlay(i)}
onclick={(event) => event.stopPropagation()}
class="flex min-w-0 flex-1 text-left"
>
<MediaIdentity
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.isExplicit ?? track.explicit ?? false}
{active}
>
{#snippet overlay()}
{#if !index && active}
<EqBars overlay paused={!player.playing} />
{/if}
{/snippet}
</MediaIdentity>
</div>
{#each columns as column (column)}
<span class="hidden truncate text-center text-xs text-muted tabular-nums sm:block">
{#if column === 'plays'}
{Number(track.listensCount ?? 0)}
{:else if column === 'added' && track.addedAt}
{fmtDate(track.addedAt)}
{:else if column === 'uploaded' && track.uploadedAt}
{fmtDate(track.uploadedAt)}
{/if}
</span>
{/each}
<span class="truncate text-center text-xs text-muted tabular-nums">
{fmtTime(Number(track.duration))}
</span>
{#if rowAction}
{#if rowAction.action}
<form
method="POST"
action={rowAction.action}
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label={rowAction.label} size="xs" revealOnHover>
<rowAction.icon class="size-icon-sm" />
</IconButton>
</form>
{:else}
<IconButton
label={rowAction.label}
size="xs"
revealOnHover
onclick={() => rowAction.onclick?.(track)}
>
<rowAction.icon class="size-icon-sm" />
</IconButton>
{/if}
{/if}
</div>
{/each}
</div>
</div>