Add library manager with tracks, albums and uploads sections
This commit is contained in:
@@ -0,0 +1,143 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<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 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';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
album: Album | null;
|
||||||
|
tracks: LibraryTrack[];
|
||||||
|
memberIds: string[];
|
||||||
|
formMessage?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { album, 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]"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{#if album}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmingDelete}
|
||||||
|
onClose={() => (confirmingDelete = false)}
|
||||||
|
title="¿Eliminar «{album.title}»?"
|
||||||
|
description="Las canciones no se borran: solo se elimina el álbum."
|
||||||
|
confirmLabel="Eliminar"
|
||||||
|
action="?/deleteAlbum"
|
||||||
|
fields={{ albumId: album.id }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Album, LibraryTrack } from '$lib/types';
|
||||||
|
import { albumCover } from '$lib/utils/hrefs';
|
||||||
|
import { albumMeta } from '$lib/utils/format';
|
||||||
|
import MediaCard from '$lib/components/ui/media/MediaCard.svelte';
|
||||||
|
import MediaCreateCard from '$lib/components/ui/media/MediaCreateCard.svelte';
|
||||||
|
import LibraryAlbumEditor from './LibraryAlbumEditor.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
albums: Album[];
|
||||||
|
tracks: LibraryTrack[];
|
||||||
|
albumTrackIds: Record<string, string[]>;
|
||||||
|
formMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { albums, tracks, albumTrackIds, formMessage }: Props = $props();
|
||||||
|
|
||||||
|
let editing = $state<{ album: Album | null } | null>(null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if editing}
|
||||||
|
<div class="mt-4">
|
||||||
|
{#key editing.album?.id}
|
||||||
|
<LibraryAlbumEditor
|
||||||
|
album={editing.album}
|
||||||
|
{tracks}
|
||||||
|
memberIds={editing.album ? (albumTrackIds[editing.album.id] ?? []) : []}
|
||||||
|
{formMessage}
|
||||||
|
onClose={() => (editing = null)}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="mt-4 grid-cards-lg">
|
||||||
|
<MediaCreateCard
|
||||||
|
title="Nuevo álbum"
|
||||||
|
subtitle="Álbum, EP o Single"
|
||||||
|
onclick={() => (editing = { album: null })}
|
||||||
|
/>
|
||||||
|
{#each albums as album, i (album.id)}
|
||||||
|
<MediaCard
|
||||||
|
href="/albums/{album.id}"
|
||||||
|
title={album.title}
|
||||||
|
subtitle={albumMeta(album.releaseYear, album.trackCount)}
|
||||||
|
trackIds={album.coverTrackIds}
|
||||||
|
src={albumCover(album.id, 'large')}
|
||||||
|
index={i + 1}
|
||||||
|
onclick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
editing = { album };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
|
import Music from '@lucide/svelte/icons/music';
|
||||||
|
import SearchX from '@lucide/svelte/icons/search-x';
|
||||||
|
import type { LibraryTrack } from '$lib/types';
|
||||||
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import { filterLibraryTracks, TRACK_FILTERS, type TrackFilter } from '$lib/data/libraryTracks';
|
||||||
|
import { plural } from '$lib/utils/format';
|
||||||
|
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 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';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tracks: LibraryTrack[];
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { tracks, errorMessage }: Props = $props();
|
||||||
|
|
||||||
|
let query = $state('');
|
||||||
|
let filter = $state<TrackFilter>('all');
|
||||||
|
let selectedIds = $state<string[]>([]);
|
||||||
|
let pendingDelete = $state<LibraryTrack[]>([]);
|
||||||
|
|
||||||
|
const visible = $derived(filterLibraryTracks(tracks, query, filter));
|
||||||
|
const filterOptions = $derived(
|
||||||
|
TRACK_FILTERS.map(({ value, label }) => ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
count: filterLibraryTracks(tracks, '', value).length
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const pendingTitle = $derived(
|
||||||
|
pendingDelete.length === 1
|
||||||
|
? `«${pendingDelete[0].title}»`
|
||||||
|
: plural(pendingDelete.length, 'canción', 'canciones')
|
||||||
|
);
|
||||||
|
|
||||||
|
function togglePlay(index: number) {
|
||||||
|
player.playOrToggle(visible, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const visibleIds = new Set(visible.map((track) => track.id));
|
||||||
|
if (selectedIds.some((id) => !visibleIds.has(id))) {
|
||||||
|
selectedIds = selectedIds.filter((id) => visibleIds.has(id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function deleteSelected() {
|
||||||
|
const ids = new Set(selectedIds);
|
||||||
|
pendingDelete = visible.filter((track) => ids.has(track.id));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<Alert tone="danger" class="mt-4">{errorMessage}</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#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
|
||||||
|
>
|
||||||
|
<Button size="sm" variant="danger" onclick={deleteSelected}>Borrar</Button>
|
||||||
|
<Button size="sm" variant="secondary" onclick={() => (selectedIds = [])}>Deseleccionar</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="mt-4 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3">
|
||||||
|
<SearchInput
|
||||||
|
bind:value={query}
|
||||||
|
placeholder="Filtrar por título o artista"
|
||||||
|
class="w-full sm:max-w-search sm:flex-1"
|
||||||
|
/>
|
||||||
|
<ChipGroup label="Filtrar por estado" options={filterOptions} bind:value={filter} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if visible.length > 0}
|
||||||
|
<ManageTrackList
|
||||||
|
class="mt-4"
|
||||||
|
tracks={visible}
|
||||||
|
bind:selectedIds
|
||||||
|
onPlay={togglePlay}
|
||||||
|
onDelete={(track) => (pendingDelete = [track])}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<EmptyState
|
||||||
|
icon={SearchX}
|
||||||
|
title="Sin resultados"
|
||||||
|
description="Ninguna canción coincide con la búsqueda o el filtro."
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<EmptyState
|
||||||
|
icon={Music}
|
||||||
|
title="Todavía no tienes canciones"
|
||||||
|
description="Sube la primera y aparecerá aquí lista para reproducir."
|
||||||
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button href="/upload">
|
||||||
|
<Upload class="size-icon-sm" />
|
||||||
|
Sube la primera
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</EmptyState>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingDelete.length > 0}
|
||||||
|
onClose={() => (pendingDelete = [])}
|
||||||
|
title="¿Borrar {pendingTitle}?"
|
||||||
|
description="Esta acción no se puede deshacer: se eliminarán de tu biblioteca, de tus álbumes y de las playlists donde estén."
|
||||||
|
confirmLabel="Borrar"
|
||||||
|
action="?/deleteTracks"
|
||||||
|
fields={{ trackIds: pendingDelete.map((track) => track.id).join(',') }}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import UploadCloud from '@lucide/svelte/icons/cloud-upload';
|
||||||
|
import { genreInfo } from '$lib/data/genres';
|
||||||
|
import {
|
||||||
|
filesFromDrop,
|
||||||
|
readDuration,
|
||||||
|
summarizeQueue,
|
||||||
|
toQueuedUpload,
|
||||||
|
UPLOAD_FORMATS,
|
||||||
|
type QueuedUpload,
|
||||||
|
type Visibility
|
||||||
|
} from '$lib/data/uploadQueue';
|
||||||
|
import { plural } from '$lib/utils/format';
|
||||||
|
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[] }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { genres }: Props = $props();
|
||||||
|
|
||||||
|
const ACCEPT = UPLOAD_FORMATS.map((ext) => `.${ext}`).join(',');
|
||||||
|
|
||||||
|
let queue = $state<QueuedUpload[]>([]);
|
||||||
|
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 visibilityLabel = $derived(visibility === 'private' ? 'Privado' : 'Público');
|
||||||
|
const queueMeta = $derived(
|
||||||
|
[
|
||||||
|
plural(summary.ready, 'lista', 'listas'),
|
||||||
|
summary.failed > 0 ? `${summary.failed} con error` : null
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
);
|
||||||
|
|
||||||
|
function enqueue(files: File[]) {
|
||||||
|
const added = files.map(toQueuedUpload);
|
||||||
|
queue = [...queue, ...added];
|
||||||
|
for (const item of added) {
|
||||||
|
if (item.error) continue;
|
||||||
|
readDuration(item.file).then((duration) => {
|
||||||
|
queue = queue.map((current) =>
|
||||||
|
current.id === item.id ? { ...current, duration } : current
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: string) {
|
||||||
|
queue = queue.filter((item) => item.id !== id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<UploadSettings
|
||||||
|
{genres}
|
||||||
|
bind:selectedGenres
|
||||||
|
bind:visibility
|
||||||
|
bind:coauthors
|
||||||
|
bind:title={() => single?.title ?? '', (value) => single && setTitle(single.id, value)}
|
||||||
|
titleDisabled={single === null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="min-w-0 rounded-panel bg-surface p-5 sm:p-6">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-display text-xl font-semibold tracking-display text-fg">Cola de subida</h2>
|
||||||
|
<p class="mt-1 text-xs text-fg-2">
|
||||||
|
{queue.length > 0 ? queueMeta : 'Todavía no hay archivos'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if queue.length > 0}
|
||||||
|
<Button variant="secondary" size="sm" onclick={() => (queue = [])}>Vaciar cola</Button>
|
||||||
|
{/if}
|
||||||
|
<Button disabled>Añadir {summary.ready} a la biblioteca</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if queue.length > 0}
|
||||||
|
<UploadQueueList
|
||||||
|
class="mt-4"
|
||||||
|
items={queue}
|
||||||
|
{genresLabel}
|
||||||
|
{visibilityLabel}
|
||||||
|
onRemove={remove}
|
||||||
|
onTitle={setTitle}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-8 mb-6 text-center text-sm text-fg-2">
|
||||||
|
Los archivos que añadas aparecerán aquí.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { LucideIcon } from '@lucide/svelte';
|
||||||
|
import type { LibraryTrack } from '$lib/types';
|
||||||
|
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tracks: LibraryTrack[];
|
||||||
|
icon: LucideIcon;
|
||||||
|
onPick: (track: LibraryTrack) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { tracks, icon: Icon, onPick }: Props = $props();
|
||||||
|
|
||||||
|
function subtitle(track: LibraryTrack): string {
|
||||||
|
const album = track.albumTitle ? `en ${track.albumTitle}` : 'Sin álbum';
|
||||||
|
return track.artist ? `${track.artist} · ${album}` : album;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-px" role="list">
|
||||||
|
{#each tracks as track (track.id)}
|
||||||
|
<div role="listitem">
|
||||||
|
<ListRow
|
||||||
|
size="xs"
|
||||||
|
trackId={track.id}
|
||||||
|
title={track.title}
|
||||||
|
subtitle={subtitle(track)}
|
||||||
|
explicit={track.explicit}
|
||||||
|
onclick={() => onPick(track)}
|
||||||
|
class="px-3 py-2"
|
||||||
|
>
|
||||||
|
{#snippet trailing()}
|
||||||
|
<Icon class="size-icon-sm shrink-0 text-fg-2" />
|
||||||
|
{/snippet}
|
||||||
|
</ListRow>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<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}`;
|
||||||
|
</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 StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
|
||||||
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: QueuedUpload[];
|
||||||
|
genresLabel: string;
|
||||||
|
visibilityLabel: string;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onTitle: (id: string, title: string) => void;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
genresLabel,
|
||||||
|
visibilityLabel,
|
||||||
|
onRemove,
|
||||||
|
onTitle,
|
||||||
|
class: klass = ''
|
||||||
|
}: 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>
|
||||||
|
</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>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<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 { LIMITS } from '$lib/validation';
|
||||||
|
import Chip from '$lib/components/ui/primitives/Chip.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';
|
||||||
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
|
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
genres: { genre: string; incompatibleWith: string[] }[];
|
||||||
|
selectedGenres: string[];
|
||||||
|
visibility: Visibility;
|
||||||
|
coauthors: string[];
|
||||||
|
title: string;
|
||||||
|
titleDisabled: boolean;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
genres,
|
||||||
|
selectedGenres = $bindable(),
|
||||||
|
visibility = $bindable(),
|
||||||
|
coauthors = $bindable(),
|
||||||
|
title = $bindable(),
|
||||||
|
titleDisabled,
|
||||||
|
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;
|
||||||
|
const known = coauthors.some((current) => current.toLowerCase() === name.toLowerCase());
|
||||||
|
if (!known) coauthors = [...coauthors, name];
|
||||||
|
artist = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onArtistKey(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'Enter') return;
|
||||||
|
event.preventDefault();
|
||||||
|
addArtist();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="flex flex-col gap-6 {klass}">
|
||||||
|
<p class="text-xs font-medium tracking-eyebrow text-fg-2 uppercase">Aplicar a esta subida</p>
|
||||||
|
|
||||||
|
<div class="grid gap-8 sm:grid-cols-[11rem_minmax(0,1fr)]">
|
||||||
|
<Field label="Portada">
|
||||||
|
<div class="w-full max-w-44">
|
||||||
|
<ImageDropzone
|
||||||
|
name="cover"
|
||||||
|
icon={ImageIcon}
|
||||||
|
gradient
|
||||||
|
size="fill"
|
||||||
|
onselect={() => (hasCover = true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#snippet hint()}
|
||||||
|
<p>{hasCover ? 'Portada lista' : 'Haz clic para elegir una imagen'}</p>
|
||||||
|
{/snippet}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<Field label="Título" for="upload-title">
|
||||||
|
<Input
|
||||||
|
id="upload-title"
|
||||||
|
bind:value={title}
|
||||||
|
maxlength={LIMITS.trackTitle}
|
||||||
|
disabled={titleDisabled}
|
||||||
|
placeholder={titleDisabled
|
||||||
|
? 'Varios archivos · edita cada título en la cola'
|
||||||
|
: 'Nombre de la canción'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div class="grid gap-7 md:grid-cols-2">
|
||||||
|
<Field label="Visibilidad">
|
||||||
|
<SegmentedControl options={VISIBILITY} bind:value={visibility} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Artistas colaboradores" for="upload-coauthor">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="upload-coauthor"
|
||||||
|
bind:value={artist}
|
||||||
|
maxlength={LIMITS.trackTitle}
|
||||||
|
placeholder="Nombre del artista"
|
||||||
|
onkeydown={onArtistKey}
|
||||||
|
/>
|
||||||
|
<IconButton label="Añadir artista" surface onclick={addArtist}>
|
||||||
|
<Plus class="size-icon-sm" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{#if coauthors.length > 0}
|
||||||
|
<ul class="mt-3 flex flex-wrap gap-2">
|
||||||
|
{#each coauthors as name (name)}
|
||||||
|
<li
|
||||||
|
class="flex items-center gap-1 rounded-full bg-btn py-1 pr-1 pl-3.5 text-xs text-fg"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
<IconButton
|
||||||
|
label="Quitar a {name}"
|
||||||
|
size="xs"
|
||||||
|
plain
|
||||||
|
onclick={() => (coauthors = coauthors.filter((current) => current !== name))}
|
||||||
|
>
|
||||||
|
<X class="size-icon-xs" />
|
||||||
|
</IconButton>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{#snippet hint()}
|
||||||
|
<p>Aparecerán como coautores de cada canción.</p>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
@@ -4,10 +4,9 @@
|
|||||||
import type { SessionUser } from '$lib/types';
|
import type { SessionUser } from '$lib/types';
|
||||||
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
||||||
import { searchHref } from '$lib/utils/hrefs';
|
import { searchHref } from '$lib/utils/hrefs';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
|
||||||
import X from '@lucide/svelte/icons/x';
|
|
||||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
import AccountMenu from '$lib/components/shell/AccountMenu.svelte';
|
import AccountMenu from '$lib/components/shell/AccountMenu.svelte';
|
||||||
|
import SearchInput from '$lib/components/ui/primitives/SearchInput.svelte';
|
||||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@
|
|||||||
let query = $derived(
|
let query = $derived(
|
||||||
page.url.pathname === '/explore' ? (page.url.searchParams.get('q') ?? '') : ''
|
page.url.pathname === '/explore' ? (page.url.searchParams.get('q') ?? '') : ''
|
||||||
);
|
);
|
||||||
let searchInput: HTMLInputElement | undefined = $state();
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
function runSearch(term: string) {
|
function runSearch(term: string) {
|
||||||
@@ -33,16 +31,14 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onInput() {
|
function oninput() {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(() => runSearch(query.trim()), SEARCH_DEBOUNCE_MS);
|
searchTimeout = setTimeout(() => runSearch(query.trim()), SEARCH_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearQuery() {
|
function onclear() {
|
||||||
query = '';
|
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
runSearch('');
|
runSearch('');
|
||||||
searchInput?.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSubmit(event: SubmitEvent) {
|
function onSubmit(event: SubmitEvent) {
|
||||||
@@ -58,29 +54,12 @@
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<form onsubmit={onSubmit} class="max-w-search min-w-0 flex-1">
|
<form onsubmit={onSubmit} class="max-w-search min-w-0 flex-1">
|
||||||
<label
|
<SearchInput
|
||||||
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
|
bind:value={query}
|
||||||
>
|
placeholder="Canciones, artistas, álbumes o playlists"
|
||||||
<Search class="size-icon-sm shrink-0 text-fg-2" />
|
{oninput}
|
||||||
<input
|
{onclear}
|
||||||
type="text"
|
/>
|
||||||
bind:this={searchInput}
|
|
||||||
bind:value={query}
|
|
||||||
oninput={onInput}
|
|
||||||
placeholder="Canciones, artistas, álbumes o playlists"
|
|
||||||
class="w-full min-w-0 bg-transparent text-sm text-fg placeholder:text-fg-2 focus:outline-none"
|
|
||||||
/>
|
|
||||||
{#if query}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={clearQuery}
|
|
||||||
aria-label="Borrar búsqueda"
|
|
||||||
class="grid size-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
|
|
||||||
>
|
|
||||||
<X class="size-icon-xs" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</label>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="ml-auto shrink-0">
|
<div class="ml-auto shrink-0">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
gradient?: boolean;
|
gradient?: boolean;
|
||||||
size?: 'default' | 'hero';
|
size?: 'default' | 'hero' | 'fill';
|
||||||
onselect?: (file: File) => void;
|
onselect?: (file: File) => void;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
@@ -24,9 +24,10 @@
|
|||||||
class: klass = ''
|
class: klass = ''
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const SIZE: Record<'default' | 'hero', string> = {
|
const SIZE: Record<'default' | 'hero' | 'fill', string> = {
|
||||||
default: 'size-40 sm:size-52 rounded-control',
|
default: 'size-40 sm:size-52 rounded-control',
|
||||||
hero: 'size-cover-hero rounded-art-lg'
|
hero: 'size-cover-hero rounded-art-lg',
|
||||||
|
fill: 'aspect-square w-full rounded-art-lg'
|
||||||
};
|
};
|
||||||
|
|
||||||
let preview = $state('');
|
let preview = $state('');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
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;
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
subtitle,
|
subtitle,
|
||||||
src,
|
src,
|
||||||
trackIds = [],
|
trackIds = [],
|
||||||
|
onclick,
|
||||||
onPlay,
|
onPlay,
|
||||||
art,
|
art,
|
||||||
oncontextmenu,
|
oncontextmenu,
|
||||||
@@ -28,18 +30,18 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a {href} class="group animate-enter min-w-0 text-left" style="--i:{index}" {oncontextmenu}>
|
<a
|
||||||
|
{href}
|
||||||
|
{onclick}
|
||||||
|
class="group animate-enter min-w-0 rounded-panel p-2.5 text-left transition-colors hover:bg-hover"
|
||||||
|
style="--i:{index}"
|
||||||
|
{oncontextmenu}
|
||||||
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
{#if art}
|
{#if art}
|
||||||
{@render art()}
|
{@render art()}
|
||||||
{:else}
|
{:else}
|
||||||
<Artwork
|
<Artwork {src} {trackIds} size="fill" alt={title} class="rounded-art" />
|
||||||
{src}
|
|
||||||
{trackIds}
|
|
||||||
size="fill"
|
|
||||||
alt={title}
|
|
||||||
class="rounded-art transition duration-300 ease-snappy group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if onPlay}
|
{#if onPlay}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
onclick: () => void;
|
||||||
|
index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { title, subtitle, onclick, index = 0 }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{onclick}
|
||||||
|
class="group animate-enter min-w-0 rounded-panel p-2.5 text-left focus-ring transition-colors hover:bg-hover"
|
||||||
|
style="--i:{index}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid aspect-square w-full place-items-center rounded-art bg-[image:var(--mf-liked-grad)]"
|
||||||
|
>
|
||||||
|
<Plus class="size-icon-xl text-on-art" />
|
||||||
|
</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-2">{subtitle}</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
tone?: 'success' | 'neutral' | 'danger';
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { label, tone = 'neutral', class: klass = '' }: Props = $props();
|
||||||
|
|
||||||
|
const TONE = {
|
||||||
|
success: 'bg-accent-tint text-accent-soft',
|
||||||
|
neutral: 'bg-btn text-fg-2',
|
||||||
|
danger: 'bg-danger/15 text-danger-fg'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap {TONE[
|
||||||
|
tone
|
||||||
|
]} {klass}">{label}</span
|
||||||
|
>
|
||||||
@@ -5,11 +5,12 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
label?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
children: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { checked = $bindable(false), name, class: klass = '', children }: Props = $props();
|
let { checked = $bindable(false), name, label, class: klass = '', children }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<label class="flex items-center gap-3 text-sm text-fg-2 {klass}">
|
<label class="flex items-center gap-3 text-sm text-fg-2 {klass}">
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
{name}
|
{name}
|
||||||
|
aria-label={label}
|
||||||
bind:checked
|
bind:checked
|
||||||
class="peer size-5 cursor-pointer appearance-none rounded-tag border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
|
class="peer size-5 cursor-pointer appearance-none rounded-tag border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
|
||||||
/>
|
/>
|
||||||
@@ -24,5 +26,7 @@
|
|||||||
class="pointer-events-none absolute size-icon-xs text-ink opacity-0 transition peer-checked:opacity-100"
|
class="pointer-events-none absolute size-icon-xs text-ink opacity-0 transition peer-checked:opacity-100"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span class="cursor-pointer">{@render children()}</span>
|
{#if children}
|
||||||
|
<span class="cursor-pointer">{@render children()}</span>
|
||||||
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
type={href ? undefined : 'button'}
|
type={href ? undefined : 'button'}
|
||||||
disabled={href ? undefined : disabled}
|
disabled={href ? undefined : disabled}
|
||||||
onclick={href ? undefined : onclick}
|
onclick={href ? undefined : onclick}
|
||||||
|
aria-pressed={href ? undefined : selected}
|
||||||
class={classes}
|
class={classes}
|
||||||
>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts" generics="T extends string">
|
||||||
|
import Chip from './Chip.svelte';
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
options: readonly Option[];
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { options, value = $bindable(), label, class: klass = '' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div role="group" aria-label={label} class="flex flex-wrap gap-2 {klass}">
|
||||||
|
{#each options as option (option.value)}
|
||||||
|
<Chip
|
||||||
|
selected={value === option.value}
|
||||||
|
count={option.count}
|
||||||
|
onclick={() => (value = option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Chip>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -11,9 +11,11 @@
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
maxlength?: number;
|
maxlength?: number;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
autocomplete?: 'on' | 'off';
|
autocomplete?: 'on' | 'off';
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
oninput?: (event: Event) => void;
|
oninput?: (event: Event) => void;
|
||||||
|
onkeydown?: (event: KeyboardEvent) => void;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,9 +29,11 @@
|
|||||||
placeholder,
|
placeholder,
|
||||||
maxlength,
|
maxlength,
|
||||||
required = false,
|
required = false,
|
||||||
|
disabled = false,
|
||||||
autocomplete = 'off',
|
autocomplete = 'off',
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
oninput,
|
oninput,
|
||||||
|
onkeydown,
|
||||||
class: klass = ''
|
class: klass = ''
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -49,8 +53,10 @@
|
|||||||
{placeholder}
|
{placeholder}
|
||||||
{maxlength}
|
{maxlength}
|
||||||
{required}
|
{required}
|
||||||
|
{disabled}
|
||||||
{autocomplete}
|
{autocomplete}
|
||||||
{oninput}
|
{oninput}
|
||||||
|
{onkeydown}
|
||||||
bind:value
|
bind:value
|
||||||
class="field-control {Icon ? 'pr-4 pl-11' : 'px-4'} {klass}"
|
class="field-control {Icon ? 'pr-4 pl-11' : 'px-4'} {klass}"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Search from '@lucide/svelte/icons/search';
|
||||||
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
label?: string;
|
||||||
|
oninput?: () => void;
|
||||||
|
onclear?: () => void;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
placeholder = 'Buscar',
|
||||||
|
label = placeholder,
|
||||||
|
oninput,
|
||||||
|
onclear,
|
||||||
|
class: klass = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let input: HTMLInputElement | undefined = $state();
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
value = '';
|
||||||
|
onclear?.();
|
||||||
|
input?.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class="flex h-9.5 min-w-0 items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover {klass}"
|
||||||
|
>
|
||||||
|
<Search class="size-icon-xs shrink-0 text-fg-2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:this={input}
|
||||||
|
bind:value
|
||||||
|
{oninput}
|
||||||
|
{placeholder}
|
||||||
|
aria-label={label}
|
||||||
|
class="w-full min-w-0 bg-transparent text-xs text-fg placeholder:text-fg-2 focus:outline-none"
|
||||||
|
/>
|
||||||
|
{#if value}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={clear}
|
||||||
|
aria-label="Borrar búsqueda"
|
||||||
|
class="grid size-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
|
||||||
|
>
|
||||||
|
<X class="size-icon-xs" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { LibraryTrack } from '$lib/types';
|
||||||
|
import { filterLibraryTracks } from './libraryTracks';
|
||||||
|
|
||||||
|
function track(id: string, title: string, status: LibraryTrack['status']): LibraryTrack {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
artist: 'Artista',
|
||||||
|
ownerUserId: '1',
|
||||||
|
explicit: false,
|
||||||
|
duration: 60,
|
||||||
|
listensCount: 0,
|
||||||
|
date: '2026-01-01T00:00:00Z',
|
||||||
|
status,
|
||||||
|
genres: [],
|
||||||
|
albumTitle: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracks = [
|
||||||
|
track('1', 'Alba', 'Completed'),
|
||||||
|
track('2', 'Noche', 'Processing'),
|
||||||
|
track('3', 'Ruido', 'Pending'),
|
||||||
|
track('4', 'Roto', 'Failed')
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('filterLibraryTracks', () => {
|
||||||
|
it('returns everything for the "all" filter without a query', () => {
|
||||||
|
expect(filterLibraryTracks(tracks, '', 'all')).toHaveLength(4);
|
||||||
|
});
|
||||||
|
it('groups pending and processing tracks together', () => {
|
||||||
|
expect(filterLibraryTracks(tracks, '', 'processing').map((t) => t.id)).toEqual(['2', '3']);
|
||||||
|
});
|
||||||
|
it('combines the status filter with the text query', () => {
|
||||||
|
expect(filterLibraryTracks(tracks, ' alb ', 'published').map((t) => t.id)).toEqual(['1']);
|
||||||
|
expect(filterLibraryTracks(tracks, 'alb', 'failed')).toEqual([]);
|
||||||
|
});
|
||||||
|
it('matches the artist too', () => {
|
||||||
|
expect(filterLibraryTracks(tracks, 'artista', 'all')).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { LibraryTrack, TrackStatus } from '$lib/types';
|
||||||
|
|
||||||
|
export type TrackTone = 'success' | 'neutral' | 'danger';
|
||||||
|
|
||||||
|
export const TRACK_STATUS: Record<TrackStatus, { label: string; tone: TrackTone }> = {
|
||||||
|
Completed: { label: 'Publicada', tone: 'success' },
|
||||||
|
Processing: { label: 'Procesando', tone: 'neutral' },
|
||||||
|
Pending: { label: 'En cola', tone: 'neutral' },
|
||||||
|
Failed: { label: 'Con error', tone: 'danger' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TRACK_FILTERS = [
|
||||||
|
{ value: 'all', label: 'Todas', statuses: null },
|
||||||
|
{ value: 'published', label: 'Publicadas', statuses: ['Completed'] },
|
||||||
|
{ value: 'processing', label: 'Procesando', statuses: ['Pending', 'Processing'] },
|
||||||
|
{ value: 'failed', label: 'Con error', statuses: ['Failed'] }
|
||||||
|
] as const satisfies readonly {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
statuses: readonly TrackStatus[] | null;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type TrackFilter = (typeof TRACK_FILTERS)[number]['value'];
|
||||||
|
|
||||||
|
export function filterLibraryTracks(
|
||||||
|
tracks: LibraryTrack[],
|
||||||
|
query: string,
|
||||||
|
filter: TrackFilter
|
||||||
|
): LibraryTrack[] {
|
||||||
|
const statuses: readonly TrackStatus[] | null =
|
||||||
|
TRACK_FILTERS.find((option) => option.value === filter)?.statuses ?? null;
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
return tracks.filter(
|
||||||
|
(track) =>
|
||||||
|
(!statuses || statuses.includes(track.status)) &&
|
||||||
|
(!needle ||
|
||||||
|
track.title.toLowerCase().includes(needle) ||
|
||||||
|
(track.artist ?? '').toLowerCase().includes(needle))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
summarizeQueue,
|
||||||
|
titleFromFileName,
|
||||||
|
toQueuedUpload,
|
||||||
|
uploadError,
|
||||||
|
UPLOAD_MAX_BYTES
|
||||||
|
} from '$lib/data/uploadQueue';
|
||||||
|
|
||||||
|
function file(name: string, size = 1024) {
|
||||||
|
return new File([new Uint8Array(size)], name);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('titleFromFileName', () => {
|
||||||
|
it('drops the extension and track number and humanizes underscores', () => {
|
||||||
|
expect(titleFromFileName('05_ultima_ola_master.wav')).toBe('Ultima ola master');
|
||||||
|
expect(titleFromFileName('12 - Niebla.flac')).toBe('Niebla');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the base name when nothing is left after cleaning', () => {
|
||||||
|
expect(titleFromFileName('07.mp3')).toBe('07');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('uploadError', () => {
|
||||||
|
it('accepts supported formats', () => {
|
||||||
|
for (const name of ['a.wav', 'a.FLAC', 'a.mp3', 'a.aiff']) {
|
||||||
|
expect(uploadError(file(name))).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported formats, empty and oversized files', () => {
|
||||||
|
expect(uploadError(file('portada.psd'))).toBe('Formato no compatible');
|
||||||
|
expect(uploadError(file('a.wav', 0))).toBe('Archivo vacío');
|
||||||
|
const big = { name: 'a.wav', size: UPLOAD_MAX_BYTES + 1 } as File;
|
||||||
|
expect(uploadError(big)).toBe('Supera 500 MB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('summarizeQueue', () => {
|
||||||
|
it('counts ready and failed items', () => {
|
||||||
|
const items = [file('a.wav'), file('b.mp3'), file('c.psd')].map(toQueuedUpload);
|
||||||
|
expect(summarizeQueue(items)).toEqual({ ready: 2, failed: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
export const UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
||||||
|
export const UPLOAD_FORMATS = ['wav', 'flac', 'aiff', 'aif', 'mp3'];
|
||||||
|
|
||||||
|
export type Visibility = 'private' | 'public';
|
||||||
|
|
||||||
|
export interface QueuedUpload {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
title: string;
|
||||||
|
format: string;
|
||||||
|
error: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileExtension(name: string): string {
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
return dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function titleFromFileName(name: string): string {
|
||||||
|
const base = name.replace(/\.[^.]+$/, '');
|
||||||
|
const cleaned = base
|
||||||
|
.replace(/^\d{1,3}\s*[-_.)]\s*/, '')
|
||||||
|
.replace(/[_]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (cleaned === '') return base;
|
||||||
|
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadError(file: File): string | null {
|
||||||
|
if (!UPLOAD_FORMATS.includes(fileExtension(file.name))) return 'Formato no compatible';
|
||||||
|
if (file.size > UPLOAD_MAX_BYTES) return 'Supera 500 MB';
|
||||||
|
if (file.size === 0) return 'Archivo vacío';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toQueuedUpload(file: File): QueuedUpload {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
file,
|
||||||
|
title: titleFromFileName(file.name),
|
||||||
|
format: fileExtension(file.name).toUpperCase() || '?',
|
||||||
|
error: uploadError(file),
|
||||||
|
duration: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeQueue(items: QueuedUpload[]): { ready: number; failed: number } {
|
||||||
|
const failed = items.filter((item) => item.error !== null).length;
|
||||||
|
return { ready: items.length - failed, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
|
||||||
|
const all: FileSystemEntry[] = [];
|
||||||
|
for (;;) {
|
||||||
|
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) =>
|
||||||
|
reader.readEntries(resolve, reject)
|
||||||
|
);
|
||||||
|
if (batch.length === 0) return all;
|
||||||
|
all.push(...batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filesFromEntry(entry: FileSystemEntry): Promise<File[]> {
|
||||||
|
if (entry.isFile) {
|
||||||
|
const file = await new Promise<File>((resolve, reject) =>
|
||||||
|
(entry as FileSystemFileEntry).file(resolve, reject)
|
||||||
|
);
|
||||||
|
return [file];
|
||||||
|
}
|
||||||
|
if (!entry.isDirectory) return [];
|
||||||
|
const children = await readEntries((entry as FileSystemDirectoryEntry).createReader());
|
||||||
|
return (await Promise.all(children.map(filesFromEntry))).flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function filesFromDrop(transfer: DataTransfer): Promise<File[]> {
|
||||||
|
const entries = Array.from(transfer.items)
|
||||||
|
.map((item) => item.webkitGetAsEntry?.() ?? null)
|
||||||
|
.filter((entry): entry is FileSystemEntry => entry !== null);
|
||||||
|
if (entries.length === 0) return Array.from(transfer.files);
|
||||||
|
return (await Promise.all(entries.map(filesFromEntry))).flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDuration(file: File): Promise<number | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const audio = new Audio();
|
||||||
|
const done = (value: number | null) => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
audio.preload = 'metadata';
|
||||||
|
audio.onloadedmetadata = () => done(Number.isFinite(audio.duration) ? audio.duration : null);
|
||||||
|
audio.onerror = () => done(null);
|
||||||
|
audio.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import type { components } from '$lib/api/schema';
|
import type { components } from '$lib/api/schema';
|
||||||
import type { Album, LikedTrack, ListeningStats, Mix, Paged, Playlist, Track } from '$lib/types';
|
import type {
|
||||||
|
Album,
|
||||||
|
LibraryTrack,
|
||||||
|
LikedTrack,
|
||||||
|
ListeningStats,
|
||||||
|
Mix,
|
||||||
|
Paged,
|
||||||
|
Playlist,
|
||||||
|
Track
|
||||||
|
} from '$lib/types';
|
||||||
|
|
||||||
type TrackDto = components['schemas']['TrackApplicationResponse'];
|
type TrackDto = components['schemas']['TrackApplicationResponse'];
|
||||||
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
|
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
|
||||||
@@ -30,6 +39,15 @@ export function toDatedTrack(dto: TrackDto): Track & { date: string } {
|
|||||||
return { ...toTrack(dto), date: dto.createdAt };
|
return { ...toTrack(dto), date: dto.createdAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toLibraryTrack(dto: TrackDto, albumTitle: string | null): LibraryTrack {
|
||||||
|
return {
|
||||||
|
...toDatedTrack(dto),
|
||||||
|
status: dto.audioStatus,
|
||||||
|
genres: dto.tags,
|
||||||
|
albumTitle
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function toLikedTrack(dto: TrackDto): LikedTrack {
|
export function toLikedTrack(dto: TrackDto): LikedTrack {
|
||||||
return { ...toTrack(dto), likedAt: new Date(dto.createdAt).getTime() };
|
return { ...toTrack(dto), likedAt: new Date(dto.createdAt).getTime() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ export interface Track {
|
|||||||
listensCount: number;
|
listensCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TrackStatus = 'Pending' | 'Processing' | 'Completed' | 'Failed';
|
||||||
|
|
||||||
|
export interface LibraryTrack extends Track {
|
||||||
|
date: string;
|
||||||
|
status: TrackStatus;
|
||||||
|
genres: string[];
|
||||||
|
albumTitle: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LikedTrack extends Track {
|
export interface LikedTrack extends Track {
|
||||||
likedAt: number;
|
likedAt: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<div class="grid-cards">
|
<div class="grid-cards-lg">
|
||||||
{#each items as album, i (album.id)}
|
{#each items as album, i (album.id)}
|
||||||
<MediaCard
|
<MediaCard
|
||||||
href="/albums/{album.id}"
|
href="/albums/{album.id}"
|
||||||
|
|||||||
@@ -1,32 +1,165 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail, isActionFailure, redirect } from '@sveltejs/kit';
|
||||||
import { toDatedTrack, toPage } from '$lib/server/mappers';
|
import type { Album } from '$lib/types';
|
||||||
import { apiFor, failOnError, unwrapOrError } from '$lib/server/api';
|
import { toAlbum, toLibraryTrack, toPage } from '$lib/server/mappers';
|
||||||
|
import { apiFor, failOnError, unwrapOrError, type ApiClient } from '$lib/server/api';
|
||||||
import { requireUser } from '$lib/server/guards';
|
import { requireUser } from '$lib/server/guards';
|
||||||
import { formString } from '$lib/server/forms/fields';
|
import { formFile, formString } from '$lib/server/forms/fields';
|
||||||
|
import { parseAlbumForm } from '$lib/server/forms/albumForm';
|
||||||
|
import { uploadOptionalCover } from '$lib/server/upload';
|
||||||
import { authedAction } from '$lib/server/actions/authedAction';
|
import { authedAction } from '$lib/server/actions/authedAction';
|
||||||
import { LIBRARY_PAGE_SIZE } from '$lib/config';
|
import { ALBUMS_PAGE_SIZE, ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PAGE_SIZE } from '$lib/config';
|
||||||
|
|
||||||
|
const ALBUMS_SECTION_URL = '/library?section=albums';
|
||||||
|
|
||||||
|
async function trackIdsByAlbum(api: ApiClient, albums: Album[]): Promise<Record<string, string[]>> {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
albums.map(async (album) => {
|
||||||
|
const tracks = await api.GET('/albums/{id}/tracks', {
|
||||||
|
params: {
|
||||||
|
path: { id: album.id },
|
||||||
|
query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [album.id, (tracks.data?.items ?? []).map((track) => track.id)] as const;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
function albumTitleByTrackId(albums: Album[], trackIds: Record<string, string[]>) {
|
||||||
|
return new Map(
|
||||||
|
albums.flatMap((album) => (trackIds[album.id] ?? []).map((id) => [id, album.title] as const))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formIds(form: FormData, key: string): string[] {
|
||||||
|
return formString(form, key).split(',').filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||||
const user = requireUser(locals, url);
|
const user = requireUser(locals, url);
|
||||||
const api = apiFor({ fetch, locals });
|
const api = apiFor({ fetch, locals });
|
||||||
const result = await api.GET('/tracks/users/{userId}', {
|
const [tracksResult, albumsResult, genresResult] = await Promise.all([
|
||||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: LIBRARY_PAGE_SIZE } }
|
api.GET('/tracks/users/{userId}', {
|
||||||
});
|
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: LIBRARY_PAGE_SIZE } }
|
||||||
|
}),
|
||||||
|
api.GET('/albums/users/{userId}', {
|
||||||
|
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } }
|
||||||
|
}),
|
||||||
|
api.GET('/genres/available')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const albums = toPage(unwrapOrError(albumsResult, 'No se pudieron cargar tus álbumes.'), toAlbum);
|
||||||
|
const albumTrackIds = await trackIdsByAlbum(api, albums.items);
|
||||||
|
const albumTitles = albumTitleByTrackId(albums.items, albumTrackIds);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tracks: toPage(unwrapOrError(result, 'No se pudo cargar tu biblioteca.'), toDatedTrack)
|
tracks: toPage(unwrapOrError(tracksResult, 'No se pudo cargar tu biblioteca.'), (dto) =>
|
||||||
|
toLibraryTrack(dto, albumTitles.get(dto.id) ?? null)
|
||||||
|
),
|
||||||
|
albums,
|
||||||
|
albumTrackIds,
|
||||||
|
genres: genresResult.data ?? []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
function tagged<T extends object>(
|
||||||
deleteTrack: authedAction(async ({ api, form }) => {
|
action: string,
|
||||||
const trackId = formString(form, 'trackId');
|
handler: (event: Parameters<Parameters<typeof authedAction>[0]>[0]) => Promise<T>
|
||||||
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
) {
|
||||||
|
return authedAction(async (event) => {
|
||||||
|
const result = await handler(event);
|
||||||
|
return isActionFailure(result)
|
||||||
|
? fail(result.status, { ...(result.data as Record<string, unknown> | undefined), action })
|
||||||
|
: { ...result, action };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const result = await api.DELETE('/tracks/{id}', {
|
export const actions: Actions = {
|
||||||
params: { path: { id: trackId } }
|
deleteTracks: tagged('deleteTracks', async ({ api, form }) => {
|
||||||
});
|
const trackIds = formIds(form, 'trackIds');
|
||||||
return failOnError(result, 'No se pudo borrar la canción.') ?? { deleted: true };
|
if (trackIds.length === 0) return fail(400, { message: 'Falta la canción.' });
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
trackIds.map((id) => api.DELETE('/tracks/{id}', { params: { path: { id } } }))
|
||||||
|
);
|
||||||
|
const failed = results.find((result) => result.error);
|
||||||
|
return (
|
||||||
|
(failed &&
|
||||||
|
failOnError(
|
||||||
|
failed,
|
||||||
|
trackIds.length === 1
|
||||||
|
? 'No se pudo borrar la canción.'
|
||||||
|
: 'No se pudieron borrar todas las canciones.'
|
||||||
|
)) ?? { deleted: true }
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
saveAlbum: tagged('saveAlbum', async ({ api, form }) => {
|
||||||
|
const parsed = parseAlbumForm(form);
|
||||||
|
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
||||||
|
|
||||||
|
const existingId = formString(form, 'albumId');
|
||||||
|
const cover = await uploadOptionalCover(api, '/albums/upload-picture', formFile(form, 'cover'));
|
||||||
|
if ('failMessage' in cover)
|
||||||
|
return fail(502, { message: cover.failMessage, detail: cover.detail });
|
||||||
|
|
||||||
|
let albumId = existingId;
|
||||||
|
if (existingId) {
|
||||||
|
const updated = await api.PUT('/albums/{id}', {
|
||||||
|
params: { path: { id: existingId } },
|
||||||
|
body: parsed.body
|
||||||
|
});
|
||||||
|
const failure = failOnError(updated, 'No se pudo actualizar el álbum.');
|
||||||
|
if (failure) return failure;
|
||||||
|
|
||||||
|
if (cover.intentId) {
|
||||||
|
const picture = await api.PUT('/albums/{id}/picture', {
|
||||||
|
params: { path: { id: existingId } },
|
||||||
|
body: { pictureIntentId: cover.intentId }
|
||||||
|
});
|
||||||
|
const pictureFailure = failOnError(picture, 'No se pudo actualizar la portada.');
|
||||||
|
if (pictureFailure) return pictureFailure;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!cover.intentId) return fail(400, { message: 'La portada es obligatoria.' });
|
||||||
|
const created = await api.POST('/albums', {
|
||||||
|
body: { ...parsed.body, pictureIntentId: cover.intentId }
|
||||||
|
});
|
||||||
|
if (created.error || !created.data)
|
||||||
|
return fail(502, { message: 'No se pudo crear el álbum.' });
|
||||||
|
albumId = created.data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addIds = formIds(form, 'addTrackIds');
|
||||||
|
if (addIds.length > 0) {
|
||||||
|
const added = await api.POST('/albums/{id}/tracks', {
|
||||||
|
params: { path: { id: albumId } },
|
||||||
|
body: { trackIds: addIds }
|
||||||
|
});
|
||||||
|
const failure = failOnError(added, 'No se pudieron añadir las canciones.');
|
||||||
|
if (failure) return failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = await Promise.all(
|
||||||
|
formIds(form, 'removeTrackIds').map((trackId) =>
|
||||||
|
api.DELETE('/albums/{id}/tracks/{trackId}', { params: { path: { id: albumId, trackId } } })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const removeFailed = removed.find((result) => result.error);
|
||||||
|
const failure =
|
||||||
|
removeFailed && failOnError(removeFailed, 'No se pudieron quitar las canciones.');
|
||||||
|
return failure ?? { saved: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteAlbum: tagged('deleteAlbum', async ({ api, form }) => {
|
||||||
|
const albumId = formString(form, 'albumId');
|
||||||
|
if (!albumId) return fail(400, { message: 'Falta el álbum.' });
|
||||||
|
|
||||||
|
const result = await api.DELETE('/albums/{id}', { params: { path: { id: albumId } } });
|
||||||
|
const failure = failOnError(result, 'No se pudo eliminar el álbum.');
|
||||||
|
if (failure) return failure;
|
||||||
|
redirect(303, ALBUMS_SECTION_URL);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,81 +1,82 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
const SECTIONS = [
|
||||||
|
{ value: 'tracks', label: 'Canciones' },
|
||||||
|
{ value: 'albums', label: 'Álbumes' },
|
||||||
|
{ value: 'uploads', label: 'Subidas' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Section = (typeof SECTIONS)[number]['value'];
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Upload from '@lucide/svelte/icons/upload';
|
import { page } from '$app/state';
|
||||||
import X from '@lucide/svelte/icons/x';
|
|
||||||
import Music from '@lucide/svelte/icons/music';
|
|
||||||
import { player } from '$lib/player/player.svelte';
|
|
||||||
import { plural } from '$lib/utils/format';
|
import { plural } from '$lib/utils/format';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/layout/PageHeader.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 ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
|
import LibraryTracksSection from '$lib/components/music/library/LibraryTracksSection.svelte';
|
||||||
import TrackList from '$lib/components/music/TrackList.svelte';
|
import LibraryAlbumsSection from '$lib/components/music/library/LibraryAlbumsSection.svelte';
|
||||||
|
import LibraryUploadsSection from '$lib/components/music/library/LibraryUploadsSection.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const items = $derived(data.tracks.items);
|
const items = $derived(data.tracks.items);
|
||||||
const total = $derived(data.tracks.totalItemCount);
|
const total = $derived(data.tracks.totalItemCount);
|
||||||
|
|
||||||
let deleting = $state<(typeof items)[number] | null>(null);
|
let section = $state<Section>(
|
||||||
|
SECTIONS.find((option) => option.value === page.url.searchParams.get('section'))?.value ??
|
||||||
|
'tracks'
|
||||||
|
);
|
||||||
|
let resetKey = $state(0);
|
||||||
|
|
||||||
function togglePlay(index: number) {
|
function selectSection(value: Section) {
|
||||||
player.playOrToggle(items, index);
|
if (value === section) resetKey += 1;
|
||||||
|
section = value;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Tus canciones subidas</title>
|
<title>Gestionar música</title>
|
||||||
<meta name="description" content="Todas tus canciones subidas a Musify." />
|
<meta name="description" content="Todas tus canciones subidas a Musify." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Tus canciones subidas"
|
title="Gestionar música"
|
||||||
description="{plural(total, 'canción', 'canciones')} subida{total === 1 ? '' : 's'}."
|
description="{plural(total, 'canción', 'canciones')} subida{total === 1
|
||||||
>
|
? ''
|
||||||
{#snippet actions()}
|
: 's'}. Puedes borrarlas o reproducirlas desde aquí."
|
||||||
{#if items.length > 0}
|
/>
|
||||||
<Button href="/upload">
|
|
||||||
<Upload class="size-icon-sm" />
|
|
||||||
Subir canción
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
{#if items.length > 0}
|
<div class="flex gap-2">
|
||||||
<TrackList
|
{#each SECTIONS as option (option.value)}
|
||||||
|
<Button
|
||||||
|
variant={section === option.value ? 'primary' : 'secondary'}
|
||||||
|
onclick={() => selectSection(option.value)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if section === 'tracks'}
|
||||||
|
<LibraryTracksSection
|
||||||
tracks={items}
|
tracks={items}
|
||||||
columns={['uploaded', 'plays']}
|
errorMessage={form?.action === 'deleteTracks' ? form.message : undefined}
|
||||||
onPlay={togglePlay}
|
|
||||||
rowAction={{
|
|
||||||
onclick: (track) => (deleting = items.find((item) => item.id === track.id) ?? null),
|
|
||||||
icon: X,
|
|
||||||
label: 'Borrar canción'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
{:else if section === 'albums'}
|
||||||
|
{#key resetKey}
|
||||||
|
<LibraryAlbumsSection
|
||||||
|
albums={data.albums.items}
|
||||||
|
tracks={items}
|
||||||
|
albumTrackIds={data.albumTrackIds}
|
||||||
|
formMessage={form?.action === 'saveAlbum' || form?.action === 'deleteAlbum'
|
||||||
|
? form.message
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<LibraryUploadsSection genres={data.genres} />
|
||||||
icon={Music}
|
|
||||||
title="Todavía no tienes canciones"
|
|
||||||
description="Sube la primera y aparecerá aquí lista para reproducir."
|
|
||||||
>
|
|
||||||
{#snippet actions()}
|
|
||||||
<Button href="/upload">
|
|
||||||
<Upload class="size-icon-sm" />
|
|
||||||
Sube la primera
|
|
||||||
</Button>
|
|
||||||
{/snippet}
|
|
||||||
</EmptyState>
|
|
||||||
{/if}
|
{/if}
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={deleting !== null}
|
|
||||||
onClose={() => (deleting = null)}
|
|
||||||
title="¿Borrar «{deleting?.title ?? ''}»?"
|
|
||||||
description="Esta acción no se puede deshacer, la canción se eliminará de tu biblioteca, de tus álbumes y de las playlists donde esté."
|
|
||||||
confirmLabel="Borrar"
|
|
||||||
action="?/deleteTrack"
|
|
||||||
fields={{ trackId: deleting?.id ?? '' }}
|
|
||||||
/>
|
|
||||||
|
|||||||
@@ -271,6 +271,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
input[type='number'] {
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='number']::-webkit-inner-spin-button,
|
||||||
|
input[type='number']::-webkit-outer-spin-button {
|
||||||
|
appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
svg.lucide {
|
svg.lucide {
|
||||||
stroke-width: 1.5;
|
stroke-width: 1.5;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user