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:
@@ -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>
|
||||
Reference in New Issue
Block a user