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:
@@ -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">
|
||||
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 { albumCover } from '$lib/utils/hrefs';
|
||||
import { plural } from '$lib/utils/format';
|
||||
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 AlbumForm from '$lib/components/ui/forms/AlbumForm.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 TrackPickList from './TrackPickList.svelte';
|
||||
import AlbumTrackPicker from './AlbumTrackPicker.svelte';
|
||||
|
||||
interface Props {
|
||||
album: Album | null;
|
||||
artist: string;
|
||||
tracks: LibraryTrack[];
|
||||
memberIds: string[];
|
||||
formMessage?: string;
|
||||
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 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);
|
||||
|
||||
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>
|
||||
|
||||
<div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/saveAlbum"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update, result }) => {
|
||||
await update({ reset: false });
|
||||
submitting = false;
|
||||
if (result.type === 'success') onClose();
|
||||
};
|
||||
}}
|
||||
class="mt-6 grid gap-8 lg:grid-cols-[auto_1fr]"
|
||||
>
|
||||
<AlbumForm
|
||||
action="?/saveAlbum"
|
||||
layout="split"
|
||||
{artist}
|
||||
showType
|
||||
initialTitle={album?.title}
|
||||
initialDescription={album?.description ?? ''}
|
||||
initialReleaseYear={album?.releaseYear ?? new Date().getFullYear()}
|
||||
titlePlaceholder="Sin título"
|
||||
coverFallbackUrl={album ? albumCover(album.id, 'large') : undefined}
|
||||
coverHint="Las canciones del álbum usan esta portada."
|
||||
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="addTrackIds" value={addedIds.join(',')} />
|
||||
<input type="hidden" name="removeTrackIds" value={removedIds.join(',')} />
|
||||
|
||||
<div class="flex w-full flex-col gap-4 lg:w-64">
|
||||
<ImageDropzone
|
||||
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
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<AlbumTrackPicker {tracks} {memberIds} />
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
{#if album}
|
||||
<Button variant="danger" size="sm" onclick={() => (confirmingDelete = true)}>
|
||||
Eliminar álbum
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AlbumForm>
|
||||
|
||||
{#if album}
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.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 ChipGroup from '$lib/components/ui/primitives/ChipGroup.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 {
|
||||
tracks: LibraryTrack[];
|
||||
@@ -64,14 +65,13 @@
|
||||
|
||||
{#if tracks.length > 0}
|
||||
{#if selectedIds.length > 0}
|
||||
<div class="mt-4 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<span class="text-sm text-fg-2"
|
||||
>{plural(selectedIds.length, 'seleccionada', 'seleccionadas')}</span
|
||||
>
|
||||
<SelectionBar
|
||||
class="mt-4"
|
||||
label={plural(selectedIds.length, 'seleccionada', 'seleccionadas')}
|
||||
onClear={() => (selectedIds = [])}
|
||||
>
|
||||
<Button size="sm" variant="danger" onclick={deleteSelected}>Borrar</Button>
|
||||
<Button size="sm" variant="secondary" onclick={() => (selectedIds = [])}>Deseleccionar</Button
|
||||
>
|
||||
</div>
|
||||
</SelectionBar>
|
||||
{:else}
|
||||
<div class="mt-4 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<SearchInput
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import UploadCloud from '@lucide/svelte/icons/cloud-upload';
|
||||
import { genreInfo } from '$lib/data/genres';
|
||||
import { genreLabels } from '$lib/data/genres';
|
||||
import {
|
||||
filesFromDrop,
|
||||
readDuration,
|
||||
@@ -11,12 +10,14 @@
|
||||
type Visibility
|
||||
} from '$lib/data/uploadQueue';
|
||||
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 UploadQueueList from '$lib/components/music/library/UploadQueueList.svelte';
|
||||
import UploadSettings from '$lib/components/music/library/UploadSettings.svelte';
|
||||
|
||||
interface Props {
|
||||
genres: { genre: string; incompatibleWith: string[] }[];
|
||||
genres: GenreOption[];
|
||||
}
|
||||
|
||||
let { genres }: Props = $props();
|
||||
@@ -27,16 +28,10 @@
|
||||
let selectedGenres = $state<string[]>([]);
|
||||
let visibility = $state<Visibility>('private');
|
||||
let coauthors = $state<string[]>([]);
|
||||
let dragging = $state(false);
|
||||
let input = $state<HTMLInputElement>();
|
||||
|
||||
const single = $derived(queue.length === 1 ? queue[0] : null);
|
||||
const summary = $derived(summarizeQueue(queue));
|
||||
const genresLabel = $derived(
|
||||
selectedGenres.length > 0
|
||||
? selectedGenres.map((genre) => genreInfo(genre).label).join(', ')
|
||||
: '-'
|
||||
);
|
||||
const genresLabel = $derived(genreLabels(selectedGenres));
|
||||
const visibilityLabel = $derived(visibility === 'private' ? 'Privado' : 'Público');
|
||||
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) {
|
||||
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="flex min-w-0 flex-col gap-8">
|
||||
<div
|
||||
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}
|
||||
onchange={onPick}
|
||||
class="hidden"
|
||||
aria-label="Elegir archivos de audio"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
<FileDropzone
|
||||
accept={ACCEPT}
|
||||
title="Arrastra tus archivos aquí"
|
||||
hint="WAV, FLAC, AIFF o MP3 · hasta 500 MB por archivo · puedes soltar carpetas enteras"
|
||||
inputLabel="Elegir archivos de audio"
|
||||
onfiles={enqueue}
|
||||
readDrop={filesFromDrop}
|
||||
/>
|
||||
|
||||
<UploadSettings
|
||||
{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>
|
||||
const GRID_COLUMNS = [
|
||||
'minmax(200px,1fr)',
|
||||
'minmax(96px,220px)',
|
||||
'96px',
|
||||
'88px',
|
||||
'72px',
|
||||
'140px',
|
||||
'36px'
|
||||
].join(' ');
|
||||
const GRID_COLUMNS_MOBILE = ['minmax(120px,1fr)', '96px', '32px'].join(' ');
|
||||
const GRID_STYLE = `--mf-track-cols:${GRID_COLUMNS}; --mf-track-cols-mobile:${GRID_COLUMNS_MOBILE}`;
|
||||
import { COL, type GridColumn } from '$lib/utils/gridColumns';
|
||||
|
||||
const TEXT = 'truncate text-xs text-fg-2';
|
||||
|
||||
const COLUMNS: GridColumn[] = [
|
||||
{
|
||||
id: 'title',
|
||||
label: 'Título',
|
||||
width: 'minmax(12.5rem,1fr)',
|
||||
mobile: COL.titleMobile,
|
||||
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 lang="ts">
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { fmtTime, formatSize } from '$lib/utils/format';
|
||||
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 IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||
|
||||
@@ -38,61 +59,49 @@
|
||||
}: Props = $props();
|
||||
</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={GRID_STYLE}
|
||||
>
|
||||
<span>Título</span>
|
||||
<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}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="grid size-10 shrink-0 place-items-center rounded-control bg-surface text-xs font-medium text-fg-2"
|
||||
>
|
||||
{item.format}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<input
|
||||
value={item.title}
|
||||
oninput={(event) => onTitle(item.id, event.currentTarget.value)}
|
||||
aria-label="Título de {item.file.name}"
|
||||
class="w-full truncate rounded-tag bg-transparent text-sm text-fg transition-colors hover:bg-surface focus:bg-surface-2 focus:outline-none"
|
||||
/>
|
||||
<p class="truncate text-xs text-fg-2">{item.file.name}</p>
|
||||
</div>
|
||||
<DataGrid
|
||||
class={klass}
|
||||
columns={COLUMNS}
|
||||
rows={items}
|
||||
rowKey={(item) => item.id}
|
||||
rowClass={() => 'hover:bg-hover'}
|
||||
>
|
||||
{#snippet cell(item, column)}
|
||||
{#if column.id === 'title'}
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="grid size-10 shrink-0 place-items-center rounded-control bg-surface text-xs font-medium text-fg-2"
|
||||
>
|
||||
{item.format}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<input
|
||||
value={item.title}
|
||||
oninput={(event) => onTitle(item.id, event.currentTarget.value)}
|
||||
aria-label="Título de {item.file.name}"
|
||||
class="w-full truncate rounded-tag bg-transparent text-sm text-fg transition-colors hover:bg-surface focus:bg-surface-2 focus:outline-none"
|
||||
/>
|
||||
<p class="truncate text-xs text-fg-2">{item.file.name}</p>
|
||||
</div>
|
||||
<span class="hidden min-w-0 truncate text-xs text-fg-2 sm:block">{genresLabel}</span>
|
||||
<span class="hidden min-w-0 truncate text-xs text-fg-2 sm:block">{visibilityLabel}</span>
|
||||
<span class="hidden truncate text-xs text-fg-2 tabular-nums sm:block">
|
||||
{formatSize(item.file.size) || '-'}
|
||||
</span>
|
||||
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
|
||||
{item.duration === null ? '-' : fmtTime(item.duration)}
|
||||
</span>
|
||||
<span>
|
||||
{#if item.error}
|
||||
<StatusBadge label={item.error} tone="danger" />
|
||||
{:else}
|
||||
<StatusBadge label="Lista" tone="success" />
|
||||
{/if}
|
||||
</span>
|
||||
<IconButton label="Quitar de la cola" size="xs" onclick={() => onRemove(item.id)}>
|
||||
<X class="size-icon-sm" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if column.id === 'genres'}
|
||||
{genresLabel}
|
||||
{:else if column.id === 'access'}
|
||||
{visibilityLabel}
|
||||
{:else if column.id === 'size'}
|
||||
{formatSize(item.file.size) || '-'}
|
||||
{:else if column.id === 'duration'}
|
||||
{item.duration === null ? '-' : fmtTime(item.duration)}
|
||||
{:else if column.id === 'status'}
|
||||
{#if item.error}
|
||||
<StatusBadge label={item.error} tone="danger" />
|
||||
{:else}
|
||||
<StatusBadge label="Lista" tone="success" />
|
||||
{/if}
|
||||
{:else}
|
||||
<IconButton label="Quitar de la cola" size="xs" onclick={() => onRemove(item.id)}>
|
||||
<X class="size-icon-sm" />
|
||||
</IconButton>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataGrid>
|
||||
|
||||
@@ -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">
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { genreOptions } from '$lib/data/genres';
|
||||
import type { Visibility } from '$lib/data/uploadQueue';
|
||||
import type { GenreOption } from '$lib/types';
|
||||
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 SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte';
|
||||
import Input from '$lib/components/ui/primitives/Input.svelte';
|
||||
@@ -13,7 +21,7 @@
|
||||
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
|
||||
|
||||
interface Props {
|
||||
genres: { genre: string; incompatibleWith: string[] }[];
|
||||
genres: GenreOption[];
|
||||
selectedGenres: string[];
|
||||
visibility: Visibility;
|
||||
coauthors: string[];
|
||||
@@ -32,29 +40,9 @@
|
||||
class: klass = ''
|
||||
}: Props = $props();
|
||||
|
||||
const VISIBILITY: { value: Visibility; label: string }[] = [
|
||||
{ value: 'private', label: 'Privado' },
|
||||
{ value: 'public', label: 'Público' }
|
||||
];
|
||||
|
||||
let artist = $state('');
|
||||
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() {
|
||||
const name = artist.trim();
|
||||
if (name === '') return;
|
||||
@@ -90,23 +78,7 @@
|
||||
</Field>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-7">
|
||||
<Field label="Géneros">
|
||||
<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>
|
||||
<GenrePicker {genres} bind:selected={selectedGenres} />
|
||||
|
||||
<Field label="Título" for="upload-title">
|
||||
<Input
|
||||
|
||||
Reference in New Issue
Block a user