Extract shared data grid, selection bar, file dropzone and genre picker components

Track lists and the upload queue now render through a declarative DataGrid, the album editor reuses AlbumForm with a split layout and a track picker, and genre labels and types are shared helpers.
This commit is contained in:
2026-09-25 01:17:59 +02:00
parent 797bed8e7d
commit 074ea7bf26
26 changed files with 879 additions and 663 deletions
@@ -0,0 +1,58 @@
<script lang="ts">
import type { GenreOption } from '$lib/types';
import { genreOptions } from '$lib/data/genres';
import Chip from '$lib/components/ui/primitives/Chip.svelte';
import Field from '$lib/components/ui/primitives/Field.svelte';
interface Props {
genres: GenreOption[];
selected: string[];
name?: string;
help?: string;
}
let {
genres,
selected = $bindable(),
name,
help = 'Elige varios. Algunos no se pueden combinar.'
}: Props = $props();
const options = $derived(genreOptions(genres));
const blocked = $derived(
new Set(
options
.filter((option) => selected.includes(option.genre))
.flatMap((option) => option.incompatibleWith)
)
);
function toggle(genre: string) {
selected = selected.includes(genre)
? selected.filter((current) => current !== genre)
: [...selected, genre];
}
</script>
<Field label="Géneros">
<div class="flex flex-wrap gap-2">
{#each options as option (option.genre)}
<Chip
selected={selected.includes(option.genre)}
disabled={blocked.has(option.genre)}
onclick={() => toggle(option.genre)}
>
{option.label}
</Chip>
{/each}
</div>
{#if name}
{#each selected as genre (genre)}
<input type="hidden" {name} value={genre} />
{/each}
{/if}
{#snippet hint()}
<p>{help}</p>
<span class="shrink-0 tabular-nums">{selected.length} elegidos</span>
{/snippet}
</Field>
@@ -1,143 +0,0 @@
<script lang="ts" module>
type ColumnId = 'album' | 'genres' | 'status';
const COLUMNS: { id: ColumnId; label: string; width: string }[] = [
{ id: 'album', label: 'Álbum', width: 'minmax(96px,160px)' },
{ id: 'genres', label: 'Géneros', width: 'minmax(96px,200px)' },
{ id: 'status', label: 'Estado', width: '112px' }
];
const CHECK_W = '28px';
const TITLE_W = { desktop: 'minmax(180px,1fr)', mobile: 'minmax(120px,1fr)' };
const DURATION_W = { desktop: '80px', mobile: '52px' };
const ACTION_W = { desktop: '36px', mobile: '32px' };
</script>
<script lang="ts">
import X from '@lucide/svelte/icons/x';
import type { LibraryTrack } from '$lib/types';
import { player } from '$lib/player/player.svelte';
import { genreInfo } from '$lib/data/genres';
import { TRACK_STATUS } from '$lib/data/libraryTracks';
import { fmtTime } from '$lib/utils/format';
import { pressable } from '$lib/actions/pressable';
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
import EqBars from '$lib/components/ui/media/EqBars.svelte';
import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
interface Props {
tracks: LibraryTrack[];
selectedIds: string[];
onPlay: (index: number) => void;
onDelete: (track: LibraryTrack) => void;
class?: string;
}
let { tracks, selectedIds = $bindable(), onPlay, onDelete, class: klass = '' }: Props = $props();
const selected = $derived(new Set(selectedIds));
const allSelected = $derived(
tracks.length > 0 && tracks.every((track) => selected.has(track.id))
);
const gridColumns = $derived(
[
CHECK_W,
TITLE_W.desktop,
...COLUMNS.map((c) => c.width),
DURATION_W.desktop,
ACTION_W.desktop
].join(' ')
);
const gridColumnsMobile = $derived(
[CHECK_W, TITLE_W.mobile, DURATION_W.mobile, ACTION_W.mobile].join(' ')
);
const gridStyle = $derived(
`--mf-track-cols:${gridColumns}; --mf-track-cols-mobile:${gridColumnsMobile}`
);
function toggleAll(checked: boolean) {
selectedIds = checked ? tracks.map((track) => track.id) : [];
}
function toggle(id: string, checked: boolean) {
selectedIds = checked ? [...selectedIds, id] : selectedIds.filter((item) => item !== id);
}
function genresLabel(track: LibraryTrack): string {
return track.genres.map((genre) => genreInfo(genre).label).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-2 sm:gap-6 sm:pr-6 sm:pl-3"
style={gridStyle}
>
<Checkbox label="Seleccionar todas" bind:checked={() => allSelected, toggleAll} />
<span>Título</span>
{#each COLUMNS as column (column.id)}
<span class="hidden sm:block">{column.label}</span>
{/each}
<span class="hidden text-center sm:block">Duración</span>
<span></span>
</div>
<div class="mt-1 flex flex-col gap-0.5" role="list">
{#each tracks as track, i (track.id)}
{@const active = player.currentId === track.id}
<div
role="listitem"
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-6 sm:pr-6 sm:pl-3 {active
? 'surface-active'
: 'hover:bg-hover'}"
style={gridStyle}
>
<Checkbox
label="Seleccionar «{track.title}»"
bind:checked={() => selected.has(track.id), (checked) => toggle(track.id, checked)}
/>
<div
role="button"
tabindex="0"
use:pressable={() => onPlay(i)}
class="flex min-w-0 flex-1 text-left"
>
<MediaIdentity
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.explicit}
{active}
>
{#snippet overlay()}
{#if active}
<EqBars overlay paused={!player.playing} />
{/if}
{/snippet}
</MediaIdentity>
</div>
{#each COLUMNS as column (column.id)}
<span class="hidden min-w-0 truncate text-xs text-fg-2 sm:block">
{#if column.id === 'album'}
{track.albumTitle ?? '-'}
{:else if column.id === 'genres'}
{genresLabel(track)}
{:else}
{@const status = TRACK_STATUS[track.status]}
<StatusBadge label={status.label} tone={status.tone} />
{/if}
</span>
{/each}
<span class="truncate text-center text-xs text-fg-2 tabular-nums">
{fmtTime(track.duration)}
</span>
<IconButton label="Borrar canción" size="xs" revealOnHover onclick={() => onDelete(track)}>
<X class="size-icon-sm" />
</IconButton>
</div>
{/each}
</div>
</div>
@@ -1,20 +1,34 @@
<script lang="ts" module> <script lang="ts" module>
import type { Track } from '$lib/types'; import type { Track } from '$lib/types';
import { COL, type GridColumn } from '$lib/utils/gridColumns';
export type TrackListColumn = 'added' | 'uploaded' | 'plays'; export type TrackListColumn = 'added' | 'uploaded' | 'plays';
export type TrackListTrack = Track & { date?: string | number }; export type TrackListTrack = Track & { date?: string | number };
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = { const COLUMN_PRESET: Record<TrackListColumn, GridColumn> = {
added: { label: 'Añadida', width: '124px' }, added: {
uploaded: { label: 'Subida', width: '124px' }, id: 'added',
plays: { label: 'Escuchas', width: '104px' } label: 'Añadida',
width: COL.date,
align: 'center',
class: 'truncate text-xs text-fg-2 tabular-nums'
},
uploaded: {
id: 'uploaded',
label: 'Subida',
width: COL.date,
align: 'center',
class: 'truncate text-xs text-fg-2 tabular-nums'
},
plays: {
id: 'plays',
label: 'Escuchas',
width: COL.plays,
align: 'center',
class: 'truncate text-xs text-fg-2 tabular-nums'
}
}; };
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>
<script lang="ts"> <script lang="ts">
@@ -24,8 +38,8 @@
import Play from '@lucide/svelte/icons/play'; import Play from '@lucide/svelte/icons/play';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { fmtTime, fmtDate } from '$lib/utils/format'; import { fmtTime, fmtDate } from '$lib/utils/format';
import { pressable } from '$lib/actions/pressable'; import TrackTitleCell from '$lib/components/music/TrackTitleCell.svelte';
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte'; import DataGrid from '$lib/components/ui/media/DataGrid.svelte';
import EqBars from '$lib/components/ui/media/EqBars.svelte'; import EqBars from '$lib/components/ui/media/EqBars.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte'; import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
@@ -56,60 +70,46 @@
class: klass = '' class: klass = ''
}: Props = $props(); }: Props = $props();
const gridColumns = $derived( const gridColumns = $derived<GridColumn[]>([
[ ...(index
index ? INDEX_W.desktop : '', ? [
TITLE_W.desktop, {
...columns.map((c) => COLUMN_PRESET[c].width), id: 'index',
DURATION_W.desktop, width: COL.index,
rowAction ? ACTION_W.desktop : '' mobile: COL.indexMobile,
customHead: true
}
] ]
.filter(Boolean) : []),
.join(' ') { id: 'title', label: 'Título', width: COL.title, mobile: COL.titleMobile, mobileLabel: true },
); ...columns.map((column) => COLUMN_PRESET[column]),
{
const gridColumnsMobile = $derived( id: 'duration',
[ label: 'Duración',
index ? INDEX_W.mobile : '', width: COL.durationWide,
TITLE_W.mobile, mobile: COL.durationMobile,
DURATION_W.mobile, align: 'center',
rowAction ? ACTION_W.mobile : '' class: 'truncate text-xs text-fg-2 tabular-nums'
] },
.filter(Boolean) ...(rowAction ? [{ id: 'action', width: COL.action, mobile: COL.actionMobile }] : [])
.join(' ') ]);
);
</script> </script>
<div class="overflow-x-auto {klass}"> <DataGrid
<div class={klass}
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-2 sm:gap-8 sm:pr-6 sm:pl-3" columns={gridColumns}
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}" rows={tracks}
rowKey={(track) => track.id}
gap="lg"
rowClass={(track) => (player.currentId === track.id ? 'surface-active' : 'hover:bg-hover')}
oncontextmenu={oncontextmenu ? (event, track, i) => oncontextmenu(event, track, i) : undefined}
> >
{#if index} {#snippet head()}
<Hash class="size-icon-sm" /> <Hash class="size-icon-sm" />
{/if} {/snippet}
<span>Título</span> {#snippet cell(track, column, i)}
{#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" role="list">
{#each tracks as track, i (track.id)}
{@const active = player.currentId === track.id} {@const active = player.currentId === track.id}
{#if column.id === 'index'}
<div
role="listitem"
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
? 'surface-active'
: 'hover:bg-hover'}"
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, track, i) : undefined}
>
{#if index}
<button <button
type="button" type="button"
onclick={() => onPlay(i)} onclick={() => onPlay(i)}
@@ -123,42 +123,17 @@
<Play class="hidden size-icon-sm text-fg group-hover:block" fill="currentColor" /> <Play class="hidden size-icon-sm text-fg group-hover:block" fill="currentColor" />
{/if} {/if}
</button> </button>
{/if} {:else if column.id === 'title'}
<TrackTitleCell {track} {active} eqOverlay={!index} onPlay={() => onPlay(i)} />
<div {:else if column.id === 'plays'}
role="button"
tabindex="0"
use:pressable={() => onPlay(i)}
class="flex min-w-0 flex-1 text-left"
>
<MediaIdentity
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.explicit}
{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-fg-2 tabular-nums sm:block">
{#if column === 'plays'}
{track.listensCount} {track.listensCount}
{:else if track.date} {:else if column.id === 'added' || column.id === 'uploaded'}
{#if track.date}
{fmtDate(track.date)} {fmtDate(track.date)}
{/if} {/if}
</span> {:else if column.id === 'duration'}
{/each}
<span class="truncate text-center text-xs text-fg-2 tabular-nums">
{fmtTime(track.duration)} {fmtTime(track.duration)}
</span> {:else if rowAction}
{#if rowAction}
{#if rowAction.action} {#if rowAction.action}
<form method="POST" action={rowAction.action} use:enhance> <form method="POST" action={rowAction.action} use:enhance>
<input type="hidden" name="trackId" value={track.id} /> <input type="hidden" name="trackId" value={track.id} />
@@ -177,7 +152,5 @@
</IconButton> </IconButton>
{/if} {/if}
{/if} {/if}
</div> {/snippet}
{/each} </DataGrid>
</div>
</div>
@@ -0,0 +1,16 @@
<script lang="ts">
import type { TrackStatus } from '$lib/types';
import { TRACK_STATUS } from '$lib/data/libraryTracks';
import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
interface Props {
status: TrackStatus;
class?: string;
}
let { status, class: klass = '' }: Props = $props();
const info = $derived(TRACK_STATUS[status]);
</script>
<StatusBadge label={info.label} tone={info.tone} class={klass} />
@@ -0,0 +1,33 @@
<script lang="ts">
import type { Track } from '$lib/types';
import { player } from '$lib/player/player.svelte';
import { pressable } from '$lib/actions/pressable';
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
import EqBars from '$lib/components/ui/media/EqBars.svelte';
interface Props {
track: Track;
active: boolean;
eqOverlay?: boolean;
onPlay: () => void;
}
let { track, active, eqOverlay = true, onPlay }: Props = $props();
</script>
<div role="button" tabindex="0" use:pressable={onPlay} class="flex min-w-0 flex-1 text-left">
<MediaIdentity
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.explicit}
{active}
>
{#snippet overlay()}
{#if eqOverlay && active}
<EqBars overlay paused={!player.playing} />
{/if}
{/snippet}
</MediaIdentity>
</div>
@@ -0,0 +1,60 @@
<script lang="ts">
import { untrack } from 'svelte';
import Plus from '@lucide/svelte/icons/plus';
import X from '@lucide/svelte/icons/x';
import type { LibraryTrack } from '$lib/types';
import { plural } from '$lib/utils/format';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import TrackPickList from './TrackPickList.svelte';
interface Props {
tracks: LibraryTrack[];
memberIds: string[];
}
let { tracks, memberIds }: Props = $props();
let currentIds = $state<string[]>(untrack(() => [...memberIds]));
const initialIds = untrack(() => new Set(memberIds));
const current = $derived(new Set(currentIds));
const inAlbum = $derived(tracks.filter((track) => current.has(track.id)));
const available = $derived(tracks.filter((track) => !current.has(track.id)));
const addedIds = $derived(currentIds.filter((id) => !initialIds.has(id)));
const removedIds = $derived([...initialIds].filter((id) => !current.has(id)));
function add(track: LibraryTrack) {
currentIds = [...currentIds, track.id];
}
function remove(track: LibraryTrack) {
currentIds = currentIds.filter((id) => id !== track.id);
}
</script>
<input type="hidden" name="addTrackIds" value={addedIds.join(',')} />
<input type="hidden" name="removeTrackIds" value={removedIds.join(',')} />
<div class="min-w-0">
<h2 class="text-display-3">Canciones</h2>
<p class="mt-1 text-sm text-fg-2">
{inAlbum.length > 0 ? plural(inAlbum.length, 'canción', 'canciones') : 'Sin canciones'}
</p>
{#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} />
{/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} />
{:else}
<EmptyState
title="Este álbum aún no tiene canciones"
description="Añádelas desde tu biblioteca o sube archivos nuevos."
/>
{/if}
</div>
</div>
@@ -1,184 +1,57 @@
<script lang="ts" module>
const ALBUM_TYPES = [
{ value: 'album', label: 'Álbum' },
{ value: 'ep', label: 'EP' },
{ value: 'single', label: 'Single' }
] as const;
type AlbumType = (typeof ALBUM_TYPES)[number]['value'];
</script>
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import { page } from '$app/state';
import Disc3 from '@lucide/svelte/icons/disc-3';
import Plus from '@lucide/svelte/icons/plus';
import X from '@lucide/svelte/icons/x';
import type { Album, LibraryTrack } from '$lib/types'; import type { Album, LibraryTrack } from '$lib/types';
import { albumCover } from '$lib/utils/hrefs'; import { albumCover } from '$lib/utils/hrefs';
import { plural } from '$lib/utils/format'; import AlbumForm from '$lib/components/ui/forms/AlbumForm.svelte';
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
import Alert from '$lib/components/ui/primitives/Alert.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import Field from '$lib/components/ui/primitives/Field.svelte';
import Input from '$lib/components/ui/primitives/Input.svelte';
import SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte';
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
import TrackPickList from './TrackPickList.svelte'; import AlbumTrackPicker from './AlbumTrackPicker.svelte';
interface Props { interface Props {
album: Album | null; album: Album | null;
artist: string;
tracks: LibraryTrack[]; tracks: LibraryTrack[];
memberIds: string[]; memberIds: string[];
formMessage?: string; formMessage?: string;
onClose: () => void; onClose: () => void;
} }
let { album, tracks, memberIds, formMessage, onClose }: Props = $props(); let { album, artist, tracks, memberIds, formMessage, onClose }: Props = $props();
const isNew = $derived(album === null); const isNew = $derived(album === null);
const latestYear = new Date().getFullYear() + 1;
let title = $state(untrack(() => album?.title ?? ''));
let releaseYear = $state(untrack(() => String(album?.releaseYear ?? new Date().getFullYear())));
let type = $state<AlbumType>('album');
let currentIds = $state<string[]>(untrack(() => [...memberIds]));
let submitting = $state(false);
let confirmingDelete = $state(false); let confirmingDelete = $state(false);
const initialIds = untrack(() => new Set(memberIds));
const current = $derived(new Set(currentIds));
const inAlbum = $derived(tracks.filter((track) => current.has(track.id)));
const available = $derived(tracks.filter((track) => !current.has(track.id)));
const addedIds = $derived(currentIds.filter((id) => !initialIds.has(id)));
const removedIds = $derived([...initialIds].filter((id) => !current.has(id)));
function add(track: LibraryTrack) {
currentIds = [...currentIds, track.id];
}
function remove(track: LibraryTrack) {
currentIds = currentIds.filter((id) => id !== track.id);
}
</script> </script>
<div> <AlbumForm
<form
method="POST"
action="?/saveAlbum" action="?/saveAlbum"
enctype="multipart/form-data" layout="split"
use:enhance={() => { {artist}
submitting = true; showType
return async ({ update, result }) => { initialTitle={album?.title}
await update({ reset: false }); initialDescription={album?.description ?? ''}
submitting = false; initialReleaseYear={album?.releaseYear ?? new Date().getFullYear()}
if (result.type === 'success') onClose(); titlePlaceholder="Sin título"
}; coverFallbackUrl={album ? albumCover(album.id, 'large') : undefined}
}} coverHint="Las canciones del álbum usan esta portada."
class="mt-6 grid gap-8 lg:grid-cols-[auto_1fr]" requireCover={isNew}
{formMessage}
submitLabel={isNew ? 'Crear álbum' : 'Guardar cambios'}
submittingLabel={isNew ? 'Creando…' : 'Guardando…'}
onCancel={onClose}
onSuccess={onClose}
> >
{#snippet aside()}
<input type="hidden" name="albumId" value={album?.id ?? ''} /> <input type="hidden" name="albumId" value={album?.id ?? ''} />
<input type="hidden" name="addTrackIds" value={addedIds.join(',')} /> <AlbumTrackPicker {tracks} {memberIds} />
<input type="hidden" name="removeTrackIds" value={removedIds.join(',')} /> {/snippet}
{#snippet footer()}
<div class="flex w-full flex-col gap-4 lg:w-64"> {#if album}
<ImageDropzone <Button variant="danger" size="sm" onclick={() => (confirmingDelete = true)}>
name="cover"
fallbackUrl={album ? albumCover(album.id, 'large') : undefined}
required={isNew}
icon={Disc3}
gradient
size="fill"
/>
<p class="text-xs text-fg-2">Las canciones del álbum usan esta portada.</p>
<Field label="Título" for="album-title">
<Input
id="album-title"
name="title"
maxlength={LIMITS.albumTitle}
required
bind:value={title}
placeholder="Sin título"
/>
</Field>
<Field label="Artista principal" for="album-artist">
<Input id="album-artist" value={page.data.user?.name ?? ''} disabled />
</Field>
<Field label="Tipo">
<SegmentedControl options={[...ALBUM_TYPES]} bind:value={type} />
</Field>
<Field label="Año de lanzamiento" for="album-year">
<Input
id="album-year"
name="releaseYear"
type="number"
min={ALBUM_EARLIEST_YEAR}
max={latestYear}
bind:value={releaseYear}
/>
</Field>
</div>
<div class="min-w-0">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-display-3">Canciones</h2>
<p class="mt-1 text-sm text-fg-2">
{inAlbum.length > 0 ? plural(inAlbum.length, 'canción', 'canciones') : 'Sin canciones'}
</p>
</div>
<Button variant="secondary" size="sm" onclick={onClose}>Cerrar</Button>
</div>
{#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} />
{/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} />
{:else}
<EmptyState
title="Este álbum aún no tiene canciones"
description="Añádelas desde tu biblioteca o sube archivos nuevos."
/>
{/if}
</div>
{#if formMessage}
<Alert tone="danger" class="mt-6">{formMessage}</Alert>
{/if}
<div class="mt-8 flex items-center justify-between gap-4">
<Button type="submit" disabled={submitting || title.trim() === ''}>
{#if submitting}
{isNew ? 'Creando…' : 'Guardando…'}
{:else}
{isNew ? 'Crear álbum' : 'Guardar cambios'}
{/if}
</Button>
{#if !isNew}
<button
type="button"
onclick={() => (confirmingDelete = true)}
class="text-sm text-danger-fg transition-opacity hover:opacity-80"
>
Eliminar álbum Eliminar álbum
</button> </Button>
{/if} {/if}
</div> {/snippet}
</div> </AlbumForm>
</form>
</div>
{#if album} {#if album}
<ConfirmDialog <ConfirmDialog
@@ -9,10 +9,11 @@
import Alert from '$lib/components/ui/primitives/Alert.svelte'; import Alert from '$lib/components/ui/primitives/Alert.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte'; import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte';
import SelectionBar from '$lib/components/ui/primitives/SelectionBar.svelte';
import SearchInput from '$lib/components/ui/primitives/SearchInput.svelte'; import SearchInput from '$lib/components/ui/primitives/SearchInput.svelte';
import ChipGroup from '$lib/components/ui/primitives/ChipGroup.svelte'; import ChipGroup from '$lib/components/ui/primitives/ChipGroup.svelte';
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
import ManageTrackList from '$lib/components/music/ManageTrackList.svelte'; import ManageTrackList from '$lib/components/music/library/ManageTrackList.svelte';
interface Props { interface Props {
tracks: LibraryTrack[]; tracks: LibraryTrack[];
@@ -64,14 +65,13 @@
{#if tracks.length > 0} {#if tracks.length > 0}
{#if selectedIds.length > 0} {#if selectedIds.length > 0}
<div class="mt-4 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3"> <SelectionBar
<span class="text-sm text-fg-2" class="mt-4"
>{plural(selectedIds.length, 'seleccionada', 'seleccionadas')}</span label={plural(selectedIds.length, 'seleccionada', 'seleccionadas')}
onClear={() => (selectedIds = [])}
> >
<Button size="sm" variant="danger" onclick={deleteSelected}>Borrar</Button> <Button size="sm" variant="danger" onclick={deleteSelected}>Borrar</Button>
<Button size="sm" variant="secondary" onclick={() => (selectedIds = [])}>Deseleccionar</Button </SelectionBar>
>
</div>
{:else} {:else}
<div class="mt-4 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3"> <div class="mt-4 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3">
<SearchInput <SearchInput
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import UploadCloud from '@lucide/svelte/icons/cloud-upload'; import { genreLabels } from '$lib/data/genres';
import { genreInfo } from '$lib/data/genres';
import { import {
filesFromDrop, filesFromDrop,
readDuration, readDuration,
@@ -11,12 +10,14 @@
type Visibility type Visibility
} from '$lib/data/uploadQueue'; } from '$lib/data/uploadQueue';
import { plural } from '$lib/utils/format'; import { plural } from '$lib/utils/format';
import type { GenreOption } from '$lib/types';
import FileDropzone from '$lib/components/ui/forms/FileDropzone.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte';
import UploadQueueList from '$lib/components/music/library/UploadQueueList.svelte'; import UploadQueueList from '$lib/components/music/library/UploadQueueList.svelte';
import UploadSettings from '$lib/components/music/library/UploadSettings.svelte'; import UploadSettings from '$lib/components/music/library/UploadSettings.svelte';
interface Props { interface Props {
genres: { genre: string; incompatibleWith: string[] }[]; genres: GenreOption[];
} }
let { genres }: Props = $props(); let { genres }: Props = $props();
@@ -27,16 +28,10 @@
let selectedGenres = $state<string[]>([]); let selectedGenres = $state<string[]>([]);
let visibility = $state<Visibility>('private'); let visibility = $state<Visibility>('private');
let coauthors = $state<string[]>([]); let coauthors = $state<string[]>([]);
let dragging = $state(false);
let input = $state<HTMLInputElement>();
const single = $derived(queue.length === 1 ? queue[0] : null); const single = $derived(queue.length === 1 ? queue[0] : null);
const summary = $derived(summarizeQueue(queue)); const summary = $derived(summarizeQueue(queue));
const genresLabel = $derived( const genresLabel = $derived(genreLabels(selectedGenres));
selectedGenres.length > 0
? selectedGenres.map((genre) => genreInfo(genre).label).join(', ')
: '-'
);
const visibilityLabel = $derived(visibility === 'private' ? 'Privado' : 'Público'); const visibilityLabel = $derived(visibility === 'private' ? 'Privado' : 'Público');
const queueMeta = $derived( const queueMeta = $derived(
[ [
@@ -60,19 +55,6 @@
} }
} }
function onPick(event: Event) {
const target = event.currentTarget as HTMLInputElement;
enqueue(Array.from(target.files ?? []));
target.value = '';
}
async function onDrop(event: DragEvent) {
event.preventDefault();
dragging = false;
if (!event.dataTransfer) return;
enqueue(await filesFromDrop(event.dataTransfer));
}
function setTitle(id: string, title: string) { function setTitle(id: string, title: string) {
queue = queue.map((item) => (item.id === id ? { ...item, title } : item)); queue = queue.map((item) => (item.id === id ? { ...item, title } : item));
} }
@@ -84,39 +66,14 @@
<div class="mt-6 grid items-start gap-6 lg:grid-cols-2"> <div class="mt-6 grid items-start gap-6 lg:grid-cols-2">
<div class="flex min-w-0 flex-col gap-8"> <div class="flex min-w-0 flex-col gap-8">
<div <FileDropzone
role="presentation"
ondragover={(event) => {
event.preventDefault();
dragging = true;
}}
ondragleave={() => (dragging = false)}
ondrop={onDrop}
class="flex min-h-64 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'
: 'border-line-strong bg-surface'}"
>
<span class="grid size-14 place-items-center rounded-full bg-surface-2 text-fg-2">
<UploadCloud class="size-icon-xl" />
</span>
<h2 class="mt-5 font-display text-xl font-semibold tracking-display text-fg">
Arrastra tus archivos aquí
</h2>
<p class="mt-2 text-xs text-fg-2">
WAV, FLAC, AIFF o MP3 · hasta 500 MB por archivo · puedes soltar carpetas enteras
</p>
<Button class="mt-6" onclick={() => input?.click()}>Elegir archivos</Button>
<input
bind:this={input}
type="file"
multiple
accept={ACCEPT} accept={ACCEPT}
onchange={onPick} title="Arrastra tus archivos aquí"
class="hidden" hint="WAV, FLAC, AIFF o MP3 · hasta 500 MB por archivo · puedes soltar carpetas enteras"
aria-label="Elegir archivos de audio" inputLabel="Elegir archivos de audio"
tabindex="-1" onfiles={enqueue}
readDrop={filesFromDrop}
/> />
</div>
<UploadSettings <UploadSettings
{genres} {genres}
@@ -0,0 +1,92 @@
<script lang="ts" module>
import { COL, type GridColumn } from '$lib/utils/gridColumns';
const TEXT = 'truncate text-xs text-fg-2';
const COLUMNS: GridColumn[] = [
{ id: 'select', width: COL.check, mobile: COL.check, customHead: true },
{ id: 'title', label: 'Título', width: COL.title, mobile: COL.titleMobile, mobileLabel: true },
{ id: 'album', label: 'Álbum', width: COL.album, class: TEXT },
{ id: 'genres', label: 'Géneros', width: COL.genres, class: TEXT },
{ id: 'status', label: 'Estado', width: COL.status },
{
id: 'duration',
label: 'Duración',
width: COL.duration,
mobile: COL.durationMobile,
align: 'center',
class: `${TEXT} tabular-nums`
},
{ id: 'delete', width: COL.action, mobile: COL.actionMobile }
];
</script>
<script lang="ts">
import X from '@lucide/svelte/icons/x';
import type { LibraryTrack } from '$lib/types';
import { player } from '$lib/player/player.svelte';
import { genreLabels } from '$lib/data/genres';
import { fmtTime } from '$lib/utils/format';
import TrackTitleCell from '$lib/components/music/TrackTitleCell.svelte';
import TrackStatusBadge from '$lib/components/music/TrackStatusBadge.svelte';
import DataGrid from '$lib/components/ui/media/DataGrid.svelte';
import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
interface Props {
tracks: LibraryTrack[];
selectedIds: string[];
onPlay: (index: number) => void;
onDelete: (track: LibraryTrack) => void;
class?: string;
}
let { tracks, selectedIds = $bindable(), onPlay, onDelete, class: klass = '' }: Props = $props();
const selected = $derived(new Set(selectedIds));
const allSelected = $derived(
tracks.length > 0 && tracks.every((track) => selected.has(track.id))
);
function toggleAll(checked: boolean) {
selectedIds = checked ? tracks.map((track) => track.id) : [];
}
function toggle(id: string, checked: boolean) {
selectedIds = checked ? [...selectedIds, id] : selectedIds.filter((item) => item !== id);
}
</script>
<DataGrid
class={klass}
columns={COLUMNS}
rows={tracks}
rowKey={(track) => track.id}
rowClass={(track) => (player.currentId === track.id ? 'surface-active' : 'hover:bg-hover')}
>
{#snippet head()}
<Checkbox label="Seleccionar todas" bind:checked={() => allSelected, toggleAll} />
{/snippet}
{#snippet cell(track, column, i)}
{#if column.id === 'select'}
<Checkbox
label="Seleccionar «{track.title}»"
bind:checked={() => selected.has(track.id), (checked) => toggle(track.id, checked)}
/>
{:else if column.id === 'title'}
<TrackTitleCell {track} active={player.currentId === track.id} onPlay={() => onPlay(i)} />
{:else if column.id === 'album'}
{track.albumTitle ?? '-'}
{:else if column.id === 'genres'}
{genreLabels(track.genres)}
{:else if column.id === 'status'}
<TrackStatusBadge status={track.status} />
{:else if column.id === 'duration'}
{fmtTime(track.duration)}
{:else}
<IconButton label="Borrar canción" size="xs" revealOnHover onclick={() => onDelete(track)}>
<X class="size-icon-sm" />
</IconButton>
{/if}
{/snippet}
</DataGrid>
@@ -1,21 +1,42 @@
<script lang="ts" module> <script lang="ts" module>
const GRID_COLUMNS = [ import { COL, type GridColumn } from '$lib/utils/gridColumns';
'minmax(200px,1fr)',
'minmax(96px,220px)', const TEXT = 'truncate text-xs text-fg-2';
'96px',
'88px', const COLUMNS: GridColumn[] = [
'72px', {
'140px', id: 'title',
'36px' label: 'Título',
].join(' '); width: 'minmax(12.5rem,1fr)',
const GRID_COLUMNS_MOBILE = ['minmax(120px,1fr)', '96px', '32px'].join(' '); mobile: COL.titleMobile,
const GRID_STYLE = `--mf-track-cols:${GRID_COLUMNS}; --mf-track-cols-mobile:${GRID_COLUMNS_MOBILE}`; mobileLabel: true
},
{ id: 'genres', label: 'Géneros', width: COL.genresWide, class: TEXT },
{ id: 'access', label: 'Acceso', width: COL.access, class: TEXT },
{ id: 'size', label: 'Tamaño', width: COL.size, class: `${TEXT} tabular-nums` },
{
id: 'duration',
label: 'Duración',
width: COL.durationQueue,
align: 'center',
class: `${TEXT} tabular-nums`
},
{
id: 'status',
label: 'Estado',
width: COL.statusQueue,
mobile: COL.statusMobile,
mobileLabel: true
},
{ id: 'remove', width: COL.action, mobile: COL.actionMobile }
];
</script> </script>
<script lang="ts"> <script lang="ts">
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import { fmtTime, formatSize } from '$lib/utils/format'; import { fmtTime, formatSize } from '$lib/utils/format';
import type { QueuedUpload } from '$lib/data/uploadQueue'; import type { QueuedUpload } from '$lib/data/uploadQueue';
import DataGrid from '$lib/components/ui/media/DataGrid.svelte';
import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte'; import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte'; import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
@@ -38,26 +59,15 @@
}: Props = $props(); }: Props = $props();
</script> </script>
<div class="overflow-x-auto {klass}"> <DataGrid
<div class={klass}
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-2 sm:gap-6 sm:pr-6 sm:pl-3" columns={COLUMNS}
style={GRID_STYLE} rows={items}
> rowKey={(item) => item.id}
<span>Título</span> rowClass={() => 'hover:bg-hover'}
<span class="hidden sm:block">Géneros</span>
<span class="hidden sm:block">Acceso</span>
<span class="hidden sm:block">Tamaño</span>
<span class="hidden text-center sm:block">Duración</span>
<span>Estado</span>
<span></span>
</div>
<div class="mt-1 flex flex-col gap-0.5" role="list">
{#each items as item (item.id)}
<div
role="listitem"
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors hover:bg-hover sm:gap-6 sm:pr-6 sm:pl-3"
style={GRID_STYLE}
> >
{#snippet cell(item, column)}
{#if column.id === 'title'}
<div class="flex min-w-0 items-center gap-3"> <div class="flex min-w-0 items-center gap-3">
<span <span
class="grid size-10 shrink-0 place-items-center rounded-control bg-surface text-xs font-medium text-fg-2" class="grid size-10 shrink-0 place-items-center rounded-control bg-surface text-xs font-medium text-fg-2"
@@ -74,25 +84,24 @@
<p class="truncate text-xs text-fg-2">{item.file.name}</p> <p class="truncate text-xs text-fg-2">{item.file.name}</p>
</div> </div>
</div> </div>
<span class="hidden min-w-0 truncate text-xs text-fg-2 sm:block">{genresLabel}</span> {:else if column.id === 'genres'}
<span class="hidden min-w-0 truncate text-xs text-fg-2 sm:block">{visibilityLabel}</span> {genresLabel}
<span class="hidden truncate text-xs text-fg-2 tabular-nums sm:block"> {:else if column.id === 'access'}
{visibilityLabel}
{:else if column.id === 'size'}
{formatSize(item.file.size) || '-'} {formatSize(item.file.size) || '-'}
</span> {:else if column.id === 'duration'}
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
{item.duration === null ? '-' : fmtTime(item.duration)} {item.duration === null ? '-' : fmtTime(item.duration)}
</span> {:else if column.id === 'status'}
<span>
{#if item.error} {#if item.error}
<StatusBadge label={item.error} tone="danger" /> <StatusBadge label={item.error} tone="danger" />
{:else} {:else}
<StatusBadge label="Lista" tone="success" /> <StatusBadge label="Lista" tone="success" />
{/if} {/if}
</span> {:else}
<IconButton label="Quitar de la cola" size="xs" onclick={() => onRemove(item.id)}> <IconButton label="Quitar de la cola" size="xs" onclick={() => onRemove(item.id)}>
<X class="size-icon-sm" /> <X class="size-icon-sm" />
</IconButton> </IconButton>
</div> {/if}
{/each} {/snippet}
</div> </DataGrid>
</div>
@@ -1,11 +1,19 @@
<script lang="ts" module>
import type { Visibility } from '$lib/data/uploadQueue';
const VISIBILITY: { value: Visibility; label: string }[] = [
{ value: 'private', label: 'Privado' },
{ value: 'public', label: 'Público' }
];
</script>
<script lang="ts"> <script lang="ts">
import ImageIcon from '@lucide/svelte/icons/image'; import ImageIcon from '@lucide/svelte/icons/image';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import { genreOptions } from '$lib/data/genres'; import type { GenreOption } from '$lib/types';
import type { Visibility } from '$lib/data/uploadQueue';
import { LIMITS } from '$lib/validation'; import { LIMITS } from '$lib/validation';
import Chip from '$lib/components/ui/primitives/Chip.svelte'; import GenrePicker from '$lib/components/music/GenrePicker.svelte';
import Field from '$lib/components/ui/primitives/Field.svelte'; import Field from '$lib/components/ui/primitives/Field.svelte';
import SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte'; import SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte';
import Input from '$lib/components/ui/primitives/Input.svelte'; import Input from '$lib/components/ui/primitives/Input.svelte';
@@ -13,7 +21,7 @@
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte'; import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
interface Props { interface Props {
genres: { genre: string; incompatibleWith: string[] }[]; genres: GenreOption[];
selectedGenres: string[]; selectedGenres: string[];
visibility: Visibility; visibility: Visibility;
coauthors: string[]; coauthors: string[];
@@ -32,29 +40,9 @@
class: klass = '' class: klass = ''
}: Props = $props(); }: Props = $props();
const VISIBILITY: { value: Visibility; label: string }[] = [
{ value: 'private', label: 'Privado' },
{ value: 'public', label: 'Público' }
];
let artist = $state(''); let artist = $state('');
let hasCover = $state(false); let hasCover = $state(false);
const options = $derived(genreOptions(genres));
const blocked = $derived(
new Set(
options
.filter((option) => selectedGenres.includes(option.genre))
.flatMap((option) => option.incompatibleWith)
)
);
function toggleGenre(genre: string) {
selectedGenres = selectedGenres.includes(genre)
? selectedGenres.filter((current) => current !== genre)
: [...selectedGenres, genre];
}
function addArtist() { function addArtist() {
const name = artist.trim(); const name = artist.trim();
if (name === '') return; if (name === '') return;
@@ -90,23 +78,7 @@
</Field> </Field>
<div class="flex min-w-0 flex-col gap-7"> <div class="flex min-w-0 flex-col gap-7">
<Field label="Géneros"> <GenrePicker {genres} bind:selected={selectedGenres} />
<div class="flex flex-wrap gap-2">
{#each options as option (option.genre)}
<Chip
selected={selectedGenres.includes(option.genre)}
disabled={blocked.has(option.genre)}
onclick={() => toggleGenre(option.genre)}
>
{option.label}
</Chip>
{/each}
</div>
{#snippet hint()}
<p>Elige varios. Algunos no se pueden combinar.</p>
<span class="shrink-0 tabular-nums">{selectedGenres.length} elegidos</span>
{/snippet}
</Field>
<Field label="Título" for="upload-title"> <Field label="Título" for="upload-title">
<Input <Input
@@ -1,9 +1,20 @@
<script lang="ts" module>
const ALBUM_TYPES = [
{ value: 'album', label: 'Álbum' },
{ value: 'ep', label: 'EP' },
{ value: 'single', label: 'Single' }
] as const;
export type AlbumType = (typeof ALBUM_TYPES)[number]['value'];
</script>
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack, type Snippet } from 'svelte';
import Disc3 from '@lucide/svelte/icons/disc-3'; import Disc3 from '@lucide/svelte/icons/disc-3';
import CoverForm from './CoverForm.svelte'; import CoverForm from './CoverForm.svelte';
import Field from '$lib/components/ui/primitives/Field.svelte'; import Field from '$lib/components/ui/primitives/Field.svelte';
import Input from '$lib/components/ui/primitives/Input.svelte'; import Input from '$lib/components/ui/primitives/Input.svelte';
import SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte';
import Textarea from '$lib/components/ui/primitives/Textarea.svelte'; import Textarea from '$lib/components/ui/primitives/Textarea.svelte';
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation'; import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
@@ -12,12 +23,19 @@
initialTitle?: string; initialTitle?: string;
initialDescription?: string; initialDescription?: string;
initialReleaseYear?: number; initialReleaseYear?: number;
initialType?: AlbumType;
titlePlaceholder?: string; titlePlaceholder?: string;
coverFallbackUrl?: string; coverFallbackUrl?: string;
coverHint?: string;
requireCover?: boolean; requireCover?: boolean;
formMessage?: string; formMessage?: string;
submitLabel: string; submitLabel: string;
submittingLabel: string; submittingLabel: string;
layout?: 'inline' | 'split';
artist?: string;
showType?: boolean;
aside?: Snippet;
footer?: Snippet;
onCancel: () => void; onCancel: () => void;
onSuccess?: () => void; onSuccess?: () => void;
} }
@@ -27,12 +45,19 @@
initialTitle = '', initialTitle = '',
initialDescription = '', initialDescription = '',
initialReleaseYear, initialReleaseYear,
initialType = 'album',
titlePlaceholder = '', titlePlaceholder = '',
coverFallbackUrl, coverFallbackUrl,
coverHint,
requireCover = false, requireCover = false,
formMessage, formMessage,
submitLabel, submitLabel,
submittingLabel, submittingLabel,
layout = 'inline',
artist,
showType = false,
aside,
footer,
onCancel, onCancel,
onSuccess onSuccess
}: Props = $props(); }: Props = $props();
@@ -40,6 +65,7 @@
let title = $state(untrack(() => initialTitle)); let title = $state(untrack(() => initialTitle));
let description = $state(untrack(() => initialDescription)); let description = $state(untrack(() => initialDescription));
let releaseYear = $state(untrack(() => (initialReleaseYear ? String(initialReleaseYear) : ''))); let releaseYear = $state(untrack(() => (initialReleaseYear ? String(initialReleaseYear) : '')));
let type = $state<AlbumType>(untrack(() => initialType));
const latestYear = new Date().getFullYear() + 1; const latestYear = new Date().getFullYear() + 1;
</script> </script>
@@ -47,12 +73,16 @@
<CoverForm <CoverForm
{action} {action}
{coverFallbackUrl} {coverFallbackUrl}
{coverHint}
{requireCover} {requireCover}
coverIcon={Disc3} coverIcon={Disc3}
{formMessage} {formMessage}
{submitLabel} {submitLabel}
{submittingLabel} {submittingLabel}
canSubmit={title.trim() !== ''} canSubmit={title.trim() !== ''}
{layout}
{aside}
{footer}
{onCancel} {onCancel}
{onSuccess} {onSuccess}
> >
@@ -71,6 +101,19 @@
/> />
</Field> </Field>
{#if artist !== undefined}
<Field label="Artista principal" for="album-artist">
<Input id="album-artist" value={artist} disabled />
</Field>
{/if}
{#if showType}
<Field label="Tipo">
<SegmentedControl options={[...ALBUM_TYPES]} bind:value={type} />
<input type="hidden" name="type" value={type} />
</Field>
{/if}
<Field label="Año de publicación" for="album-year"> <Field label="Año de publicación" for="album-year">
<Input <Input
id="album-year" id="album-year"
@@ -17,10 +17,14 @@
submittingLabel: string; submittingLabel: string;
canSubmit: boolean; canSubmit: boolean;
helperText?: string; helperText?: string;
layout?: 'inline' | 'split';
coverHint?: string;
onCancel: () => void; onCancel: () => void;
onSuccess?: () => void; onSuccess?: () => void;
fields: Snippet; fields: Snippet;
extra?: Snippet; extra?: Snippet;
aside?: Snippet;
footer?: Snippet;
} }
let { let {
@@ -33,13 +37,19 @@
submittingLabel, submittingLabel,
canSubmit, canSubmit,
helperText, helperText,
layout = 'inline',
coverHint,
onCancel, onCancel,
onSuccess, onSuccess,
fields, fields,
extra extra,
aside,
footer
}: Props = $props(); }: Props = $props();
let submitting = $state(false); let submitting = $state(false);
const split = $derived(layout === 'split');
</script> </script>
<form <form
@@ -54,8 +64,32 @@
if (result.type === 'success' || result.type === 'redirect') onSuccess?.(); if (result.type === 'success' || result.type === 'redirect') onSuccess?.();
}; };
}} }}
class="mt-5 w-full max-w-4xl space-y-5" class={split ? 'mt-6 grid gap-8 lg:grid-cols-[auto_1fr]' : 'mt-5 w-full max-w-4xl space-y-5'}
> >
{#if split}
<div class="flex w-full flex-col gap-4 lg:w-64">
<ImageDropzone
name="cover"
fallbackUrl={coverFallbackUrl}
required={requireCover}
icon={coverIcon}
gradient
size="fill"
/>
{#if coverHint}
<p class="text-xs text-fg-2">{coverHint}</p>
{/if}
{@render fields()}
{@render extra?.()}
</div>
<div class="min-w-0">
{@render aside?.()}
<div class="mt-6 space-y-5">
{@render feedback()}
{@render actions()}
</div>
</div>
{:else}
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8"> <div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
<Field> <Field>
<ImageDropzone <ImageDropzone
@@ -73,15 +107,23 @@
</div> </div>
{@render extra?.()} {@render extra?.()}
{@render feedback()}
{@render actions()}
{/if}
</form>
{#snippet feedback()}
{#if formMessage} {#if formMessage}
<Alert tone="danger">{formMessage}</Alert> <Alert tone="danger">{formMessage}</Alert>
{/if} {/if}
{/snippet}
{#snippet actions()}
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
{#if helperText} {#if helperText}
<p class="text-sm text-fg-2">{helperText}</p> <p class="text-sm text-fg-2">{helperText}</p>
{/if} {/if}
{@render footer?.()}
<div class="ml-auto flex shrink-0 items-center gap-3"> <div class="ml-auto flex shrink-0 items-center gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button> <Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || !canSubmit}> <Button type="submit" disabled={submitting || !canSubmit}>
@@ -89,4 +131,4 @@
</Button> </Button>
</div> </div>
</div> </div>
</form> {/snippet}
@@ -0,0 +1,95 @@
<script lang="ts">
import UploadCloud from '@lucide/svelte/icons/cloud-upload';
import Button from '$lib/components/ui/primitives/Button.svelte';
interface Props {
accept: string;
title: string;
hint?: string;
buttonLabel?: string;
inputLabel?: string;
multiple?: boolean;
onfiles: (files: File[]) => void;
readDrop?: (transfer: DataTransfer) => Promise<File[]>;
class?: string;
}
let {
accept,
title,
hint,
buttonLabel = 'Elegir archivos',
inputLabel = buttonLabel,
multiple = true,
onfiles,
readDrop = async (transfer) => Array.from(transfer.files),
class: klass = ''
}: Props = $props();
let depth = $state(0);
let input = $state<HTMLInputElement>();
const dragging = $derived(depth > 0);
function hasFiles(event: DragEvent): boolean {
return event.dataTransfer?.types.includes('Files') ?? false;
}
function onEnter(event: DragEvent) {
if (!hasFiles(event)) return;
event.preventDefault();
depth += 1;
}
function onOver(event: DragEvent) {
if (hasFiles(event)) event.preventDefault();
}
function onLeave(event: DragEvent) {
if (!hasFiles(event)) return;
depth = Math.max(0, depth - 1);
}
async function onDrop(event: DragEvent) {
event.preventDefault();
depth = 0;
if (!event.dataTransfer) return;
onfiles(await readDrop(event.dataTransfer));
}
function onPick(event: Event) {
const target = event.currentTarget as HTMLInputElement;
onfiles(Array.from(target.files ?? []));
target.value = '';
}
</script>
<div
role="presentation"
ondragenter={onEnter}
ondragover={onOver}
ondragleave={onLeave}
ondrop={onDrop}
class="flex min-h-64 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'
: 'border-line-strong bg-surface'} {klass}"
>
<span class="grid size-14 place-items-center rounded-full bg-surface-2 text-fg-2">
<UploadCloud class="size-icon-xl" />
</span>
<h2 class="mt-5 font-display text-xl font-semibold tracking-display text-fg">{title}</h2>
{#if hint}
<p class="mt-2 text-xs text-fg-2">{hint}</p>
{/if}
<Button class="mt-6" onclick={() => input?.click()}>{buttonLabel}</Button>
<input
bind:this={input}
type="file"
{multiple}
{accept}
onchange={onPick}
class="hidden"
aria-label={inputLabel}
tabindex="-1"
/>
</div>
@@ -0,0 +1,77 @@
<script lang="ts" module>
const GAP = { md: 'sm:gap-6', lg: 'sm:gap-8' };
</script>
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import { gridColumns, type GridColumn } from '$lib/utils/gridColumns';
interface Props {
columns: readonly GridColumn[];
rows: readonly T[];
rowKey: (row: T) => string;
cell: Snippet<[T, GridColumn, number]>;
head?: Snippet<[GridColumn]>;
rowClass?: (row: T, index: number) => string;
oncontextmenu?: (event: MouseEvent, row: T, index: number) => void;
gap?: 'md' | 'lg';
class?: string;
}
let {
columns,
rows,
rowKey,
cell,
head,
rowClass,
oncontextmenu,
gap = 'md',
class: klass = ''
}: Props = $props();
const style = $derived(gridColumns(columns));
function cellClass(column: GridColumn): string {
return [column.mobile ? '' : 'hidden sm:block', column.align === 'center' ? 'text-center' : '']
.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-2 sm:pr-6 sm:pl-3 {GAP[
gap
]}"
{style}
>
{#each columns as column (column.id)}
<div class="min-w-0 {cellClass(column)}">
{#if head && column.customHead}
{@render head(column)}
{:else if column.label}
<span class={column.mobileLabel ? '' : 'hidden sm:inline'}>{column.label}</span>
{/if}
</div>
{/each}
</div>
<div class="mt-1 flex flex-col gap-0.5" role="list">
{#each rows as row, i (rowKey(row))}
<div
role="listitem"
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}
oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, row, i) : undefined}
>
{#each columns as column (column.id)}
<div class="min-w-0 {cellClass(column)} {column.class ?? ''}">
{@render cell(row, column, i)}
</div>
{/each}
</div>
{/each}
</div>
</div>
@@ -9,7 +9,6 @@
subtitle?: string | null; subtitle?: string | null;
src?: string | null; src?: string | null;
trackIds?: string[]; trackIds?: string[];
onclick?: (event: MouseEvent) => void;
onPlay?: (event: MouseEvent) => void; onPlay?: (event: MouseEvent) => void;
art?: Snippet; art?: Snippet;
oncontextmenu?: (event: MouseEvent) => void; oncontextmenu?: (event: MouseEvent) => void;
@@ -22,7 +21,6 @@
subtitle, subtitle,
src, src,
trackIds = [], trackIds = [],
onclick,
onPlay, onPlay,
art, art,
oncontextmenu, oncontextmenu,
@@ -32,7 +30,6 @@
<a <a
{href} {href}
{onclick}
class="group animate-enter min-w-0 rounded-panel p-2.5 text-left transition-colors hover:bg-hover" class="group animate-enter min-w-0 rounded-panel p-2.5 text-left transition-colors hover:bg-hover"
style="--i:{index}" style="--i:{index}"
{oncontextmenu} {oncontextmenu}
@@ -0,0 +1,19 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Button from './Button.svelte';
interface Props {
label: string;
onClear: () => void;
class?: string;
children: Snippet;
}
let { label, onClear, class: klass = '', children }: Props = $props();
</script>
<div class="flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3 {klass}">
<span class="text-sm text-fg-2">{label}</span>
{@render children()}
<Button size="sm" variant="secondary" onclick={onClear}>Deseleccionar</Button>
</div>
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { findConflict, genreHue } from './genres'; import { findConflict, genreHue, genreLabels } from './genres';
describe('genreHue', () => { describe('genreHue', () => {
it('is stable and within 0-359', () => { it('is stable and within 0-359', () => {
@@ -26,3 +26,10 @@ describe('findConflict', () => {
expect(findConflict(['x', 'y'], options)).toBeUndefined(); expect(findConflict(['x', 'y'], options)).toBeUndefined();
}); });
}); });
describe('genreLabels', () => {
it('joins the labels and falls back to a dash', () => {
expect(genreLabels(['HipHop', 'Rock'])).toBe('Hip hop, Rock');
expect(genreLabels([])).toBe('-');
});
});
+8 -5
View File
@@ -1,3 +1,5 @@
import type { GenreOption } from '$lib/types';
const GENRE_INFO: Record<string, { label: string; tagline: string }> = { const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
Pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' }, Pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
Rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' }, Rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
@@ -45,6 +47,10 @@ export function genreHue(genre: string): number {
return hash; return hash;
} }
export function genreLabels(genres: string[]): string {
return genres.map((genre) => genreInfo(genre).label).join(', ') || '-';
}
export function genreTiles(genres: { genre: string }[]) { export function genreTiles(genres: { genre: string }[]) {
return genres.map(({ genre }) => ({ return genres.map(({ genre }) => ({
genre, genre,
@@ -53,7 +59,7 @@ export function genreTiles(genres: { genre: string }[]) {
})); }));
} }
export function genreOptions(genres: { genre: string; incompatibleWith: string[] }[]) { export function genreOptions(genres: GenreOption[]) {
return genres.map(({ genre, incompatibleWith }) => ({ return genres.map(({ genre, incompatibleWith }) => ({
genre, genre,
incompatibleWith, incompatibleWith,
@@ -61,10 +67,7 @@ export function genreOptions(genres: { genre: string; incompatibleWith: string[]
})); }));
} }
export function findConflict( export function findConflict(tags: string[], options: GenreOption[]): [string, string] | undefined {
tags: string[],
options: { genre: string; incompatibleWith: string[] }[]
): [string, string] | undefined {
const byGenre = new Map(options.map((option) => [option.genre, option.incompatibleWith])); const byGenre = new Map(options.map((option) => [option.genre, option.incompatibleWith]));
for (let i = 0; i < tags.length; i++) { for (let i = 0; i < tags.length; i++) {
for (let j = i + 1; j < tags.length; j++) { for (let j = i + 1; j < tags.length; j++) {
+5
View File
@@ -17,6 +17,11 @@ export interface Track {
listensCount: number; listensCount: number;
} }
export interface GenreOption {
genre: string;
incompatibleWith: string[];
}
export type TrackStatus = 'Pending' | 'Processing' | 'Completed' | 'Failed'; export type TrackStatus = 'Pending' | 'Processing' | 'Completed' | 'Failed';
export interface LibraryTrack extends Track { export interface LibraryTrack extends Track {
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { gridColumns } from './gridColumns';
describe('gridColumns', () => {
it('builds desktop and mobile templates, skipping columns without a mobile width', () => {
const style = gridColumns([
{ id: 'title', width: '1fr', mobile: '2fr' },
{ id: 'album', width: '10rem' },
{ id: 'action', width: '2rem', mobile: '1rem' }
]);
expect(style).toBe('--mf-track-cols:1fr 10rem 2rem; --mf-track-cols-mobile:2fr 1rem');
});
});
+41
View File
@@ -0,0 +1,41 @@
export interface GridColumn {
id: string;
label?: string;
width: string;
mobile?: string;
align?: 'center';
mobileLabel?: boolean;
customHead?: boolean;
class?: string;
}
export const COL = {
check: '1.75rem',
index: '2rem',
indexMobile: '1.75rem',
title: 'minmax(11.25rem,1fr)',
titleMobile: 'minmax(7.5rem,1fr)',
titleWide: 'minmax(12.5rem,1fr)',
action: '2.25rem',
actionMobile: '2rem',
duration: '5rem',
durationWide: '6.5rem',
durationMobile: '3.25rem',
date: '7.75rem',
plays: '6.5rem',
album: 'minmax(6rem,10rem)',
genres: 'minmax(6rem,12.5rem)',
genresWide: 'minmax(6rem,13.75rem)',
status: '7rem',
statusQueue: '8.75rem',
statusMobile: '6rem',
access: '6rem',
size: '5.5rem',
durationQueue: '4.5rem'
} as const;
export function gridColumns(columns: readonly GridColumn[]): string {
const desktop = columns.map((column) => column.width).join(' ');
const mobile = columns.flatMap((column) => (column.mobile ? [column.mobile] : [])).join(' ');
return `--mf-track-cols:${desktop}; --mf-track-cols-mobile:${mobile}`;
}
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state';
import LibraryAlbumEditor from '$lib/components/music/library/LibraryAlbumEditor.svelte'; import LibraryAlbumEditor from '$lib/components/music/library/LibraryAlbumEditor.svelte';
let { data, form } = $props(); let { data, form } = $props();
@@ -9,6 +10,7 @@
{#key data.album.id} {#key data.album.id}
<LibraryAlbumEditor <LibraryAlbumEditor
album={data.album} album={data.album}
artist={page.data.user?.name ?? ''}
tracks={data.tracks.items} tracks={data.tracks.items}
memberIds={data.memberIds} memberIds={data.memberIds}
formMessage={form?.message} formMessage={form?.message}
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state';
import LibraryAlbumEditor from '$lib/components/music/library/LibraryAlbumEditor.svelte'; import LibraryAlbumEditor from '$lib/components/music/library/LibraryAlbumEditor.svelte';
let { data, form } = $props(); let { data, form } = $props();
@@ -8,6 +9,7 @@
<div class="mt-4"> <div class="mt-4">
<LibraryAlbumEditor <LibraryAlbumEditor
album={null} album={null}
artist={page.data.user?.name ?? ''}
tracks={data.tracks.items} tracks={data.tracks.items}
memberIds={[]} memberIds={[]}
formMessage={form?.action === 'saveAlbum' ? form.message : undefined} formMessage={form?.action === 'saveAlbum' ? form.message : undefined}
@@ -14,10 +14,9 @@
import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte'; import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte';
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte'; import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
import Chip from '$lib/components/ui/primitives/Chip.svelte';
import { LIMITS } from '$lib/validation'; import { LIMITS } from '$lib/validation';
import { formatSize } from '$lib/utils/format'; import { formatSize } from '$lib/utils/format';
import { genreOptions } from '$lib/data/genres'; import GenrePicker from '$lib/components/music/GenrePicker.svelte';
type Status = 'idle' | 'uploading' | 'done' | 'error'; type Status = 'idle' | 'uploading' | 'done' | 'error';
type StepState = 'idle' | 'active' | 'done'; type StepState = 'idle' | 'active' | 'done';
@@ -44,15 +43,6 @@
{ key: 'done', label: 'Listo', hint: 'Se procesa y aparece en tu biblioteca.' } { key: 'done', label: 'Listo', hint: 'Se procesa y aparece en tu biblioteca.' }
]; ];
const options = $derived(genreOptions(data.genres));
const blocked = $derived(
new Set(
options
.filter((option) => tags.includes(option.genre))
.flatMap((option) => option.incompatibleWith)
)
);
const busy = $derived(status === 'uploading'); const busy = $derived(status === 'uploading');
const canPublish = $derived( const canPublish = $derived(
@@ -91,10 +81,6 @@
pickAudio(file); pickAudio(file);
} }
function toggleTag(tag: string) {
tags = tags.includes(tag) ? tags.filter((current) => current !== tag) : [...tags, tag];
}
function reset() { function reset() {
title = ''; title = '';
audioName = ''; audioName = '';
@@ -258,28 +244,12 @@
{/snippet} {/snippet}
</Field> </Field>
<Field label="Géneros"> <GenrePicker
<div class="mb-2 flex flex-wrap gap-2"> genres={data.genres}
{#each options as option (option.genre)} bind:selected={tags}
<Chip name="tags"
selected={tags.includes(option.genre)} help="Elige al menos uno. Algunos géneros no se pueden combinar, como Metal con Ambient."
disabled={blocked.has(option.genre)} />
onclick={() => toggleTag(option.genre)}
>
{option.label}
</Chip>
{/each}
</div>
{#each tags as tag (tag)}
<input type="hidden" name="tags" value={tag} />
{/each}
{#snippet hint()}
<p>
Elige al menos uno. Algunos géneros no se pueden combinar, como Metal con Ambient.
</p>
<span class="shrink-0 tabular-nums">{tags.length} elegidos</span>
{/snippet}
</Field>
<Checkbox bind:checked={isExplicit} name="isExplicit"> <Checkbox bind:checked={isExplicit} name="isExplicit">
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">