Merge library components and styles work
This commit is contained in:
@@ -79,7 +79,7 @@ The shell in `src/routes/(app)/+layout.svelte` is a fixed `h-dvh` frame: Sidebar
|
|||||||
|
|
||||||
## Motion
|
## Motion
|
||||||
|
|
||||||
Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover.
|
Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`. Media cards have no cover zoom: hovering one only fades in its background (`hover:bg-hover`) and reveals the play button.
|
||||||
|
|
||||||
Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items.
|
Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const ARBITRARY_WHITELIST = [
|
|||||||
/^my-\[\d+d?vh\]$/, // +error.svelte: centrado vertical de página de error
|
/^my-\[\d+d?vh\]$/, // +error.svelte: centrado vertical de página de error
|
||||||
/^py-\[\d+d?vh\]$/, // Explorar: centrado vertical de "sin resultados"
|
/^py-\[\d+d?vh\]$/, // Explorar: centrado vertical de "sin resultados"
|
||||||
/^transition-\[filter\]$/, // Explorar: genre-tile hover
|
/^transition-\[filter\]$/, // Explorar: genre-tile hover
|
||||||
|
/^transition-\[transform,width\]$/, // Tabs: indicador deslizante
|
||||||
/^bg-\[image:var\(--mf-[\w-]+\)\]$/
|
/^bg-\[image:var\(--mf-[\w-]+\)\]$/
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { GenreOption } from '$lib/types';
|
||||||
|
import { genreOptions } from '$lib/data/genres';
|
||||||
|
import Chip from '$lib/components/ui/primitives/Chip.svelte';
|
||||||
|
import Field from '$lib/components/ui/primitives/Field.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
genres: GenreOption[];
|
||||||
|
selected: string[];
|
||||||
|
name?: string;
|
||||||
|
help?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
genres,
|
||||||
|
selected = $bindable(),
|
||||||
|
name,
|
||||||
|
help = 'Elige varios. Algunos no se pueden combinar.'
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const options = $derived(genreOptions(genres));
|
||||||
|
const blocked = $derived(
|
||||||
|
new Set(
|
||||||
|
options
|
||||||
|
.filter((option) => selected.includes(option.genre))
|
||||||
|
.flatMap((option) => option.incompatibleWith)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggle(genre: string) {
|
||||||
|
selected = selected.includes(genre)
|
||||||
|
? selected.filter((current) => current !== genre)
|
||||||
|
: [...selected, genre];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Field label="Géneros">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each options as option (option.genre)}
|
||||||
|
<Chip
|
||||||
|
selected={selected.includes(option.genre)}
|
||||||
|
disabled={blocked.has(option.genre)}
|
||||||
|
onclick={() => toggle(option.genre)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Chip>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if name}
|
||||||
|
{#each selected as genre (genre)}
|
||||||
|
<input type="hidden" {name} value={genre} />
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{#snippet hint()}
|
||||||
|
<p>{help}</p>
|
||||||
|
<span class="shrink-0 tabular-nums">{selected.length} elegidos</span>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
<script lang="ts" module>
|
|
||||||
type ColumnId = 'album' | 'genres' | 'status';
|
|
||||||
|
|
||||||
const COLUMNS: { id: ColumnId; label: string; width: string }[] = [
|
|
||||||
{ id: 'album', label: 'Álbum', width: 'minmax(96px,160px)' },
|
|
||||||
{ id: 'genres', label: 'Géneros', width: 'minmax(96px,200px)' },
|
|
||||||
{ id: 'status', label: 'Estado', width: '112px' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const CHECK_W = '28px';
|
|
||||||
const TITLE_W = { desktop: 'minmax(180px,1fr)', mobile: 'minmax(120px,1fr)' };
|
|
||||||
const DURATION_W = { desktop: '80px', mobile: '52px' };
|
|
||||||
const ACTION_W = { desktop: '36px', mobile: '32px' };
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import X from '@lucide/svelte/icons/x';
|
|
||||||
import type { LibraryTrack } from '$lib/types';
|
|
||||||
import { player } from '$lib/player/player.svelte';
|
|
||||||
import { genreInfo } from '$lib/data/genres';
|
|
||||||
import { TRACK_STATUS } from '$lib/data/libraryTracks';
|
|
||||||
import { fmtTime } from '$lib/utils/format';
|
|
||||||
import { pressable } from '$lib/actions/pressable';
|
|
||||||
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
|
|
||||||
import EqBars from '$lib/components/ui/media/EqBars.svelte';
|
|
||||||
import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
|
|
||||||
import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte';
|
|
||||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tracks: LibraryTrack[];
|
|
||||||
selectedIds: string[];
|
|
||||||
onPlay: (index: number) => void;
|
|
||||||
onDelete: (track: LibraryTrack) => void;
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { tracks, selectedIds = $bindable(), onPlay, onDelete, class: klass = '' }: Props = $props();
|
|
||||||
|
|
||||||
const selected = $derived(new Set(selectedIds));
|
|
||||||
const allSelected = $derived(
|
|
||||||
tracks.length > 0 && tracks.every((track) => selected.has(track.id))
|
|
||||||
);
|
|
||||||
|
|
||||||
const gridColumns = $derived(
|
|
||||||
[
|
|
||||||
CHECK_W,
|
|
||||||
TITLE_W.desktop,
|
|
||||||
...COLUMNS.map((c) => c.width),
|
|
||||||
DURATION_W.desktop,
|
|
||||||
ACTION_W.desktop
|
|
||||||
].join(' ')
|
|
||||||
);
|
|
||||||
const gridColumnsMobile = $derived(
|
|
||||||
[CHECK_W, TITLE_W.mobile, DURATION_W.mobile, ACTION_W.mobile].join(' ')
|
|
||||||
);
|
|
||||||
const gridStyle = $derived(
|
|
||||||
`--mf-track-cols:${gridColumns}; --mf-track-cols-mobile:${gridColumnsMobile}`
|
|
||||||
);
|
|
||||||
|
|
||||||
function toggleAll(checked: boolean) {
|
|
||||||
selectedIds = checked ? tracks.map((track) => track.id) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle(id: string, checked: boolean) {
|
|
||||||
selectedIds = checked ? [...selectedIds, id] : selectedIds.filter((item) => item !== id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function genresLabel(track: LibraryTrack): string {
|
|
||||||
return track.genres.map((genre) => genreInfo(genre).label).join(', ') || '-';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="overflow-x-auto {klass}">
|
|
||||||
<div
|
|
||||||
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-2 sm:gap-6 sm:pr-6 sm:pl-3"
|
|
||||||
style={gridStyle}
|
|
||||||
>
|
|
||||||
<Checkbox label="Seleccionar todas" bind:checked={() => allSelected, toggleAll} />
|
|
||||||
<span>Título</span>
|
|
||||||
{#each COLUMNS as column (column.id)}
|
|
||||||
<span class="hidden sm:block">{column.label}</span>
|
|
||||||
{/each}
|
|
||||||
<span class="hidden text-center sm:block">Duración</span>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 flex flex-col gap-0.5" role="list">
|
|
||||||
{#each tracks as track, i (track.id)}
|
|
||||||
{@const active = player.currentId === track.id}
|
|
||||||
<div
|
|
||||||
role="listitem"
|
|
||||||
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-6 sm:pr-6 sm:pl-3 {active
|
|
||||||
? 'surface-active'
|
|
||||||
: 'hover:bg-hover'}"
|
|
||||||
style={gridStyle}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
label="Seleccionar «{track.title}»"
|
|
||||||
bind:checked={() => selected.has(track.id), (checked) => toggle(track.id, checked)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
use:pressable={() => onPlay(i)}
|
|
||||||
class="flex min-w-0 flex-1 text-left"
|
|
||||||
>
|
|
||||||
<MediaIdentity
|
|
||||||
trackId={track.id}
|
|
||||||
title={track.title}
|
|
||||||
artist={track.artist}
|
|
||||||
ownerUserId={track.ownerUserId}
|
|
||||||
explicit={track.explicit}
|
|
||||||
{active}
|
|
||||||
>
|
|
||||||
{#snippet overlay()}
|
|
||||||
{#if active}
|
|
||||||
<EqBars overlay paused={!player.playing} />
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
</MediaIdentity>
|
|
||||||
</div>
|
|
||||||
{#each COLUMNS as column (column.id)}
|
|
||||||
<span class="hidden min-w-0 truncate text-xs text-fg-2 sm:block">
|
|
||||||
{#if column.id === 'album'}
|
|
||||||
{track.albumTitle ?? '-'}
|
|
||||||
{:else if column.id === 'genres'}
|
|
||||||
{genresLabel(track)}
|
|
||||||
{:else}
|
|
||||||
{@const status = TRACK_STATUS[track.status]}
|
|
||||||
<StatusBadge label={status.label} tone={status.tone} />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
<span class="truncate text-center text-xs text-fg-2 tabular-nums">
|
|
||||||
{fmtTime(track.duration)}
|
|
||||||
</span>
|
|
||||||
<IconButton label="Borrar canción" size="xs" revealOnHover onclick={() => onDelete(track)}>
|
|
||||||
<X class="size-icon-sm" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,20 +1,34 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import type { Track } from '$lib/types';
|
import type { Track } from '$lib/types';
|
||||||
|
import { COL, type GridColumn } from '$lib/utils/gridColumns';
|
||||||
|
|
||||||
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
|
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
|
||||||
|
|
||||||
export type TrackListTrack = Track & { date?: string | number };
|
export type TrackListTrack = Track & { date?: string | number };
|
||||||
|
|
||||||
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
|
const COLUMN_PRESET: Record<TrackListColumn, GridColumn> = {
|
||||||
added: { label: 'Añadida', width: '124px' },
|
added: {
|
||||||
uploaded: { label: 'Subida', width: '124px' },
|
id: 'added',
|
||||||
plays: { label: 'Escuchas', width: '104px' }
|
label: 'Añadida',
|
||||||
|
width: COL.date,
|
||||||
|
align: 'center',
|
||||||
|
class: 'truncate text-xs text-fg-2 tabular-nums'
|
||||||
|
},
|
||||||
|
uploaded: {
|
||||||
|
id: 'uploaded',
|
||||||
|
label: 'Subida',
|
||||||
|
width: COL.date,
|
||||||
|
align: 'center',
|
||||||
|
class: 'truncate text-xs text-fg-2 tabular-nums'
|
||||||
|
},
|
||||||
|
plays: {
|
||||||
|
id: 'plays',
|
||||||
|
label: 'Escuchas',
|
||||||
|
width: COL.plays,
|
||||||
|
align: 'center',
|
||||||
|
class: 'truncate text-xs text-fg-2 tabular-nums'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const INDEX_W = { desktop: '32px', mobile: '28px' };
|
|
||||||
const TITLE_W = { desktop: 'minmax(180px,1fr)', mobile: 'minmax(120px,1fr)' };
|
|
||||||
const DURATION_W = { desktop: '104px', mobile: '52px' };
|
|
||||||
const ACTION_W = { desktop: '36px', mobile: '32px' };
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -24,8 +38,8 @@
|
|||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { fmtTime, fmtDate } from '$lib/utils/format';
|
import { fmtTime, fmtDate } from '$lib/utils/format';
|
||||||
import { pressable } from '$lib/actions/pressable';
|
import TrackTitleCell from '$lib/components/music/TrackTitleCell.svelte';
|
||||||
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
|
import DataGrid from '$lib/components/ui/media/DataGrid.svelte';
|
||||||
import EqBars from '$lib/components/ui/media/EqBars.svelte';
|
import EqBars from '$lib/components/ui/media/EqBars.svelte';
|
||||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
|
|
||||||
@@ -56,60 +70,46 @@
|
|||||||
class: klass = ''
|
class: klass = ''
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const gridColumns = $derived(
|
const gridColumns = $derived<GridColumn[]>([
|
||||||
[
|
...(index
|
||||||
index ? INDEX_W.desktop : '',
|
? [
|
||||||
TITLE_W.desktop,
|
{
|
||||||
...columns.map((c) => COLUMN_PRESET[c].width),
|
id: 'index',
|
||||||
DURATION_W.desktop,
|
width: COL.index,
|
||||||
rowAction ? ACTION_W.desktop : ''
|
mobile: COL.indexMobile,
|
||||||
|
customHead: true
|
||||||
|
}
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
: []),
|
||||||
.join(' ')
|
{ id: 'title', label: 'Título', width: COL.title, mobile: COL.titleMobile, mobileLabel: true },
|
||||||
);
|
...columns.map((column) => COLUMN_PRESET[column]),
|
||||||
|
{
|
||||||
const gridColumnsMobile = $derived(
|
id: 'duration',
|
||||||
[
|
label: 'Duración',
|
||||||
index ? INDEX_W.mobile : '',
|
width: COL.durationWide,
|
||||||
TITLE_W.mobile,
|
mobile: COL.durationMobile,
|
||||||
DURATION_W.mobile,
|
align: 'center',
|
||||||
rowAction ? ACTION_W.mobile : ''
|
class: 'truncate text-xs text-fg-2 tabular-nums'
|
||||||
]
|
},
|
||||||
.filter(Boolean)
|
...(rowAction ? [{ id: 'action', width: COL.action, mobile: COL.actionMobile }] : [])
|
||||||
.join(' ')
|
]);
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overflow-x-auto {klass}">
|
<DataGrid
|
||||||
<div
|
class={klass}
|
||||||
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-2 sm:gap-8 sm:pr-6 sm:pl-3"
|
columns={gridColumns}
|
||||||
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
|
rows={tracks}
|
||||||
>
|
rowKey={(track) => track.id}
|
||||||
{#if index}
|
gap="lg"
|
||||||
|
rowClass={(track) => (player.currentId === track.id ? 'surface-active' : 'hover:bg-hover')}
|
||||||
|
oncontextmenu={oncontextmenu ? (event, track, i) => oncontextmenu(event, track, i) : undefined}
|
||||||
|
>
|
||||||
|
{#snippet head()}
|
||||||
<Hash class="size-icon-sm" />
|
<Hash class="size-icon-sm" />
|
||||||
{/if}
|
{/snippet}
|
||||||
<span>Título</span>
|
{#snippet cell(track, column, i)}
|
||||||
{#each columns as column (column)}
|
|
||||||
<span class="hidden text-center sm:block">{COLUMN_PRESET[column].label}</span>
|
|
||||||
{/each}
|
|
||||||
<span class="hidden text-center sm:block">Duración</span>
|
|
||||||
{#if rowAction}
|
|
||||||
<span></span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 flex flex-col gap-0.5" role="list">
|
|
||||||
{#each tracks as track, i (track.id)}
|
|
||||||
{@const active = player.currentId === track.id}
|
{@const active = player.currentId === track.id}
|
||||||
|
{#if column.id === 'index'}
|
||||||
<div
|
|
||||||
role="listitem"
|
|
||||||
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-8 sm:pr-6 sm:pl-3 {active
|
|
||||||
? 'surface-active'
|
|
||||||
: 'hover:bg-hover'}"
|
|
||||||
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
|
|
||||||
oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, track, i) : undefined}
|
|
||||||
>
|
|
||||||
{#if index}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => onPlay(i)}
|
onclick={() => onPlay(i)}
|
||||||
@@ -123,42 +123,17 @@
|
|||||||
<Play class="hidden size-icon-sm text-fg group-hover:block" fill="currentColor" />
|
<Play class="hidden size-icon-sm text-fg group-hover:block" fill="currentColor" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{:else if column.id === 'title'}
|
||||||
|
<TrackTitleCell {track} {active} eqOverlay={!index} onPlay={() => onPlay(i)} />
|
||||||
<div
|
{:else if column.id === 'plays'}
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
use:pressable={() => onPlay(i)}
|
|
||||||
class="flex min-w-0 flex-1 text-left"
|
|
||||||
>
|
|
||||||
<MediaIdentity
|
|
||||||
trackId={track.id}
|
|
||||||
title={track.title}
|
|
||||||
artist={track.artist}
|
|
||||||
ownerUserId={track.ownerUserId}
|
|
||||||
explicit={track.explicit}
|
|
||||||
{active}
|
|
||||||
>
|
|
||||||
{#snippet overlay()}
|
|
||||||
{#if !index && active}
|
|
||||||
<EqBars overlay paused={!player.playing} />
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
</MediaIdentity>
|
|
||||||
</div>
|
|
||||||
{#each columns as column (column)}
|
|
||||||
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
|
|
||||||
{#if column === 'plays'}
|
|
||||||
{track.listensCount}
|
{track.listensCount}
|
||||||
{:else if track.date}
|
{:else if column.id === 'added' || column.id === 'uploaded'}
|
||||||
|
{#if track.date}
|
||||||
{fmtDate(track.date)}
|
{fmtDate(track.date)}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
{:else if column.id === 'duration'}
|
||||||
{/each}
|
|
||||||
<span class="truncate text-center text-xs text-fg-2 tabular-nums">
|
|
||||||
{fmtTime(track.duration)}
|
{fmtTime(track.duration)}
|
||||||
</span>
|
{:else if rowAction}
|
||||||
{#if rowAction}
|
|
||||||
{#if rowAction.action}
|
{#if rowAction.action}
|
||||||
<form method="POST" action={rowAction.action} use:enhance>
|
<form method="POST" action={rowAction.action} use:enhance>
|
||||||
<input type="hidden" name="trackId" value={track.id} />
|
<input type="hidden" name="trackId" value={track.id} />
|
||||||
@@ -177,7 +152,5 @@
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{/snippet}
|
||||||
{/each}
|
</DataGrid>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { TrackStatus } from '$lib/types';
|
||||||
|
import { TRACK_STATUS } from '$lib/data/libraryTracks';
|
||||||
|
import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: TrackStatus;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { status, class: klass = '' }: Props = $props();
|
||||||
|
|
||||||
|
const info = $derived(TRACK_STATUS[status]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<StatusBadge
|
||||||
|
label={info.label}
|
||||||
|
tone={info.tone}
|
||||||
|
pulse={status === 'Processing' || status === 'Pending'}
|
||||||
|
class={klass}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Track } from '$lib/types';
|
||||||
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import { pressable } from '$lib/actions/pressable';
|
||||||
|
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
|
||||||
|
import EqBars from '$lib/components/ui/media/EqBars.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
track: Track;
|
||||||
|
active: boolean;
|
||||||
|
eqOverlay?: boolean;
|
||||||
|
onPlay: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { track, active, eqOverlay = true, onPlay }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div role="button" tabindex="0" use:pressable={onPlay} class="flex min-w-0 flex-1 text-left">
|
||||||
|
<MediaIdentity
|
||||||
|
trackId={track.id}
|
||||||
|
title={track.title}
|
||||||
|
artist={track.artist}
|
||||||
|
ownerUserId={track.ownerUserId}
|
||||||
|
explicit={track.explicit}
|
||||||
|
{active}
|
||||||
|
>
|
||||||
|
{#snippet overlay()}
|
||||||
|
{#if eqOverlay && active}
|
||||||
|
<EqBars overlay paused={!player.playing} />
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</MediaIdentity>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<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 { transferBetweenLists } from '$lib/utils/transitions';
|
||||||
|
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();
|
||||||
|
|
||||||
|
const [send, receive] = transferBetweenLists();
|
||||||
|
|
||||||
|
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} {send} {receive} 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} {send} {receive} onPick={remove} />
|
||||||
|
{:else}
|
||||||
|
<EmptyState
|
||||||
|
title="Este álbum aún no tiene canciones"
|
||||||
|
description="Añádelas desde tu biblioteca o sube archivos nuevos."
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,184 +1,57 @@
|
|||||||
<script lang="ts" module>
|
|
||||||
const ALBUM_TYPES = [
|
|
||||||
{ value: 'album', label: 'Álbum' },
|
|
||||||
{ value: 'ep', label: 'EP' },
|
|
||||||
{ value: 'single', label: 'Single' }
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type AlbumType = (typeof ALBUM_TYPES)[number]['value'];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import Disc3 from '@lucide/svelte/icons/disc-3';
|
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
|
||||||
import X from '@lucide/svelte/icons/x';
|
|
||||||
import type { Album, LibraryTrack } from '$lib/types';
|
import type { Album, LibraryTrack } from '$lib/types';
|
||||||
import { albumCover } from '$lib/utils/hrefs';
|
import { albumCover } from '$lib/utils/hrefs';
|
||||||
import { plural } from '$lib/utils/format';
|
import AlbumForm from '$lib/components/ui/forms/AlbumForm.svelte';
|
||||||
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
|
|
||||||
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
|
|
||||||
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
|
||||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
|
||||||
import Field from '$lib/components/ui/primitives/Field.svelte';
|
|
||||||
import Input from '$lib/components/ui/primitives/Input.svelte';
|
|
||||||
import SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte';
|
|
||||||
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
|
||||||
import TrackPickList from './TrackPickList.svelte';
|
import AlbumTrackPicker from './AlbumTrackPicker.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
album: Album | null;
|
album: Album | null;
|
||||||
|
artist: string;
|
||||||
tracks: LibraryTrack[];
|
tracks: LibraryTrack[];
|
||||||
memberIds: string[];
|
memberIds: string[];
|
||||||
formMessage?: string;
|
formMessage?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { album, tracks, memberIds, formMessage, onClose }: Props = $props();
|
let { album, artist, tracks, memberIds, formMessage, onClose }: Props = $props();
|
||||||
|
|
||||||
const isNew = $derived(album === null);
|
const isNew = $derived(album === null);
|
||||||
const latestYear = new Date().getFullYear() + 1;
|
|
||||||
|
|
||||||
let title = $state(untrack(() => album?.title ?? ''));
|
|
||||||
let releaseYear = $state(untrack(() => String(album?.releaseYear ?? new Date().getFullYear())));
|
|
||||||
let type = $state<AlbumType>('album');
|
|
||||||
let currentIds = $state<string[]>(untrack(() => [...memberIds]));
|
|
||||||
let submitting = $state(false);
|
|
||||||
let confirmingDelete = $state(false);
|
let confirmingDelete = $state(false);
|
||||||
|
|
||||||
const initialIds = untrack(() => new Set(memberIds));
|
|
||||||
const current = $derived(new Set(currentIds));
|
|
||||||
const inAlbum = $derived(tracks.filter((track) => current.has(track.id)));
|
|
||||||
const available = $derived(tracks.filter((track) => !current.has(track.id)));
|
|
||||||
const addedIds = $derived(currentIds.filter((id) => !initialIds.has(id)));
|
|
||||||
const removedIds = $derived([...initialIds].filter((id) => !current.has(id)));
|
|
||||||
|
|
||||||
function add(track: LibraryTrack) {
|
|
||||||
currentIds = [...currentIds, track.id];
|
|
||||||
}
|
|
||||||
|
|
||||||
function remove(track: LibraryTrack) {
|
|
||||||
currentIds = currentIds.filter((id) => id !== track.id);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<AlbumForm
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/saveAlbum"
|
action="?/saveAlbum"
|
||||||
enctype="multipart/form-data"
|
layout="split"
|
||||||
use:enhance={() => {
|
{artist}
|
||||||
submitting = true;
|
showType
|
||||||
return async ({ update, result }) => {
|
initialTitle={album?.title}
|
||||||
await update({ reset: false });
|
initialDescription={album?.description ?? ''}
|
||||||
submitting = false;
|
initialReleaseYear={album?.releaseYear ?? new Date().getFullYear()}
|
||||||
if (result.type === 'success') onClose();
|
titlePlaceholder="Sin título"
|
||||||
};
|
coverFallbackUrl={album ? albumCover(album.id, 'large') : undefined}
|
||||||
}}
|
coverHint="Las canciones del álbum usan esta portada."
|
||||||
class="mt-6 grid gap-8 lg:grid-cols-[auto_1fr]"
|
requireCover={isNew}
|
||||||
>
|
{formMessage}
|
||||||
|
submitLabel={isNew ? 'Crear álbum' : 'Guardar cambios'}
|
||||||
|
submittingLabel={isNew ? 'Creando…' : 'Guardando…'}
|
||||||
|
onCancel={onClose}
|
||||||
|
onSuccess={onClose}
|
||||||
|
>
|
||||||
|
{#snippet aside()}
|
||||||
<input type="hidden" name="albumId" value={album?.id ?? ''} />
|
<input type="hidden" name="albumId" value={album?.id ?? ''} />
|
||||||
<input type="hidden" name="addTrackIds" value={addedIds.join(',')} />
|
<AlbumTrackPicker {tracks} {memberIds} />
|
||||||
<input type="hidden" name="removeTrackIds" value={removedIds.join(',')} />
|
{/snippet}
|
||||||
|
{#snippet footer()}
|
||||||
<div class="flex w-full flex-col gap-4 lg:w-64">
|
{#if album}
|
||||||
<ImageDropzone
|
<Button variant="danger" size="sm" onclick={() => (confirmingDelete = true)}>
|
||||||
name="cover"
|
|
||||||
fallbackUrl={album ? albumCover(album.id, 'large') : undefined}
|
|
||||||
required={isNew}
|
|
||||||
icon={Disc3}
|
|
||||||
gradient
|
|
||||||
size="fill"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-fg-2">Las canciones del álbum usan esta portada.</p>
|
|
||||||
|
|
||||||
<Field label="Título" for="album-title">
|
|
||||||
<Input
|
|
||||||
id="album-title"
|
|
||||||
name="title"
|
|
||||||
maxlength={LIMITS.albumTitle}
|
|
||||||
required
|
|
||||||
bind:value={title}
|
|
||||||
placeholder="Sin título"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Artista principal" for="album-artist">
|
|
||||||
<Input id="album-artist" value={page.data.user?.name ?? ''} disabled />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Tipo">
|
|
||||||
<SegmentedControl options={[...ALBUM_TYPES]} bind:value={type} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Año de lanzamiento" for="album-year">
|
|
||||||
<Input
|
|
||||||
id="album-year"
|
|
||||||
name="releaseYear"
|
|
||||||
type="number"
|
|
||||||
min={ALBUM_EARLIEST_YEAR}
|
|
||||||
max={latestYear}
|
|
||||||
bind:value={releaseYear}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-display-3">Canciones</h2>
|
|
||||||
<p class="mt-1 text-sm text-fg-2">
|
|
||||||
{inAlbum.length > 0 ? plural(inAlbum.length, 'canción', 'canciones') : 'Sin canciones'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="secondary" size="sm" onclick={onClose}>Cerrar</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if available.length > 0}
|
|
||||||
<h3 class="mt-6 mb-2 px-3 text-eyebrow text-fg-2">Añadir desde tu biblioteca</h3>
|
|
||||||
<TrackPickList tracks={available} icon={Plus} onPick={add} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-6">
|
|
||||||
{#if inAlbum.length > 0}
|
|
||||||
<h3 class="mb-2 px-3 text-eyebrow text-fg-2">En este álbum</h3>
|
|
||||||
<TrackPickList tracks={inAlbum} icon={X} onPick={remove} />
|
|
||||||
{:else}
|
|
||||||
<EmptyState
|
|
||||||
title="Este álbum aún no tiene canciones"
|
|
||||||
description="Añádelas desde tu biblioteca o sube archivos nuevos."
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if formMessage}
|
|
||||||
<Alert tone="danger" class="mt-6">{formMessage}</Alert>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-8 flex items-center justify-between gap-4">
|
|
||||||
<Button type="submit" disabled={submitting || title.trim() === ''}>
|
|
||||||
{#if submitting}
|
|
||||||
{isNew ? 'Creando…' : 'Guardando…'}
|
|
||||||
{:else}
|
|
||||||
{isNew ? 'Crear álbum' : 'Guardar cambios'}
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
{#if !isNew}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (confirmingDelete = true)}
|
|
||||||
class="text-sm text-danger-fg transition-opacity hover:opacity-80"
|
|
||||||
>
|
|
||||||
Eliminar álbum
|
Eliminar álbum
|
||||||
</button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{/snippet}
|
||||||
</div>
|
</AlbumForm>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if album}
|
{#if album}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
const FADE = { duration: 200 };
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
import Upload from '@lucide/svelte/icons/upload';
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import SearchX from '@lucide/svelte/icons/search-x';
|
import SearchX from '@lucide/svelte/icons/search-x';
|
||||||
@@ -9,10 +14,11 @@
|
|||||||
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
||||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
|
import SelectionBar from '$lib/components/ui/primitives/SelectionBar.svelte';
|
||||||
import SearchInput from '$lib/components/ui/primitives/SearchInput.svelte';
|
import SearchInput from '$lib/components/ui/primitives/SearchInput.svelte';
|
||||||
import ChipGroup from '$lib/components/ui/primitives/ChipGroup.svelte';
|
import ChipGroup from '$lib/components/ui/primitives/ChipGroup.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
|
||||||
import ManageTrackList from '$lib/components/music/ManageTrackList.svelte';
|
import ManageTrackList from '$lib/components/music/library/ManageTrackList.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tracks: LibraryTrack[];
|
tracks: LibraryTrack[];
|
||||||
@@ -63,25 +69,32 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if tracks.length > 0}
|
{#if tracks.length > 0}
|
||||||
|
<div class="mt-4 grid">
|
||||||
{#if selectedIds.length > 0}
|
{#if selectedIds.length > 0}
|
||||||
<div class="mt-4 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3">
|
<div class="col-start-1 row-start-1" in:fade={FADE} out:fade={FADE}>
|
||||||
<span class="text-sm text-fg-2"
|
<SelectionBar
|
||||||
>{plural(selectedIds.length, 'seleccionada', 'seleccionadas')}</span
|
label={plural(selectedIds.length, 'seleccionada', 'seleccionadas')}
|
||||||
|
onClear={() => (selectedIds = [])}
|
||||||
>
|
>
|
||||||
<Button size="sm" variant="danger" onclick={deleteSelected}>Borrar</Button>
|
<Button size="sm" variant="danger" onclick={deleteSelected}>Borrar</Button>
|
||||||
<Button size="sm" variant="secondary" onclick={() => (selectedIds = [])}>Deseleccionar</Button
|
</SelectionBar>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mt-4 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3">
|
<div
|
||||||
|
class="col-start-1 row-start-1 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3"
|
||||||
|
in:fade={FADE}
|
||||||
|
out:fade={FADE}
|
||||||
|
>
|
||||||
<SearchInput
|
<SearchInput
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
placeholder="Filtrar por título o artista"
|
placeholder="Filtrar por título o artista"
|
||||||
|
size="sm"
|
||||||
class="w-full sm:max-w-search sm:flex-1"
|
class="w-full sm:max-w-search sm:flex-1"
|
||||||
/>
|
/>
|
||||||
<ChipGroup label="Filtrar por estado" options={filterOptions} bind:value={filter} />
|
<ChipGroup label="Filtrar por estado" options={filterOptions} bind:value={filter} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if visible.length > 0}
|
{#if visible.length > 0}
|
||||||
<ManageTrackList
|
<ManageTrackList
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import UploadCloud from '@lucide/svelte/icons/cloud-upload';
|
import { genreLabels } from '$lib/data/genres';
|
||||||
import { genreInfo } from '$lib/data/genres';
|
|
||||||
import {
|
import {
|
||||||
filesFromDrop,
|
filesFromDrop,
|
||||||
readDuration,
|
readDuration,
|
||||||
summarizeQueue,
|
summarizeQueue,
|
||||||
toQueuedUpload,
|
toQueuedUpload,
|
||||||
UPLOAD_FORMATS,
|
UPLOAD_FORMATS,
|
||||||
|
uploadLimitsLabel,
|
||||||
type QueuedUpload,
|
type QueuedUpload,
|
||||||
type Visibility
|
type Visibility
|
||||||
} from '$lib/data/uploadQueue';
|
} from '$lib/data/uploadQueue';
|
||||||
import { plural } from '$lib/utils/format';
|
import { plural } from '$lib/utils/format';
|
||||||
|
import type { GenreOption } from '$lib/types';
|
||||||
|
import FileDropzone from '$lib/components/ui/forms/FileDropzone.svelte';
|
||||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
import UploadQueueList from '$lib/components/music/library/UploadQueueList.svelte';
|
import UploadQueueList from '$lib/components/music/library/UploadQueueList.svelte';
|
||||||
import UploadSettings from '$lib/components/music/library/UploadSettings.svelte';
|
import UploadSettings from '$lib/components/music/library/UploadSettings.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
genres: { genre: string; incompatibleWith: string[] }[];
|
genres: GenreOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { genres }: Props = $props();
|
let { genres }: Props = $props();
|
||||||
@@ -27,16 +29,10 @@
|
|||||||
let selectedGenres = $state<string[]>([]);
|
let selectedGenres = $state<string[]>([]);
|
||||||
let visibility = $state<Visibility>('private');
|
let visibility = $state<Visibility>('private');
|
||||||
let coauthors = $state<string[]>([]);
|
let coauthors = $state<string[]>([]);
|
||||||
let dragging = $state(false);
|
|
||||||
let input = $state<HTMLInputElement>();
|
|
||||||
|
|
||||||
const single = $derived(queue.length === 1 ? queue[0] : null);
|
const single = $derived(queue.length === 1 ? queue[0] : null);
|
||||||
const summary = $derived(summarizeQueue(queue));
|
const summary = $derived(summarizeQueue(queue));
|
||||||
const genresLabel = $derived(
|
const genresLabel = $derived(genreLabels(selectedGenres));
|
||||||
selectedGenres.length > 0
|
|
||||||
? selectedGenres.map((genre) => genreInfo(genre).label).join(', ')
|
|
||||||
: '-'
|
|
||||||
);
|
|
||||||
const visibilityLabel = $derived(visibility === 'private' ? 'Privado' : 'Público');
|
const visibilityLabel = $derived(visibility === 'private' ? 'Privado' : 'Público');
|
||||||
const queueMeta = $derived(
|
const queueMeta = $derived(
|
||||||
[
|
[
|
||||||
@@ -60,19 +56,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPick(event: Event) {
|
|
||||||
const target = event.currentTarget as HTMLInputElement;
|
|
||||||
enqueue(Array.from(target.files ?? []));
|
|
||||||
target.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDrop(event: DragEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
dragging = false;
|
|
||||||
if (!event.dataTransfer) return;
|
|
||||||
enqueue(await filesFromDrop(event.dataTransfer));
|
|
||||||
}
|
|
||||||
|
|
||||||
function setTitle(id: string, title: string) {
|
function setTitle(id: string, title: string) {
|
||||||
queue = queue.map((item) => (item.id === id ? { ...item, title } : item));
|
queue = queue.map((item) => (item.id === id ? { ...item, title } : item));
|
||||||
}
|
}
|
||||||
@@ -84,39 +67,14 @@
|
|||||||
|
|
||||||
<div class="mt-6 grid items-start gap-6 lg:grid-cols-2">
|
<div class="mt-6 grid items-start gap-6 lg:grid-cols-2">
|
||||||
<div class="flex min-w-0 flex-col gap-8">
|
<div class="flex min-w-0 flex-col gap-8">
|
||||||
<div
|
<FileDropzone
|
||||||
role="presentation"
|
|
||||||
ondragover={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
dragging = true;
|
|
||||||
}}
|
|
||||||
ondragleave={() => (dragging = false)}
|
|
||||||
ondrop={onDrop}
|
|
||||||
class="flex min-h-64 flex-col items-center justify-center rounded-panel border border-dashed p-6 text-center transition sm:p-10 {dragging
|
|
||||||
? 'border-accent/60 bg-accent-tint'
|
|
||||||
: 'border-line-strong bg-surface'}"
|
|
||||||
>
|
|
||||||
<span class="grid size-14 place-items-center rounded-full bg-surface-2 text-fg-2">
|
|
||||||
<UploadCloud class="size-icon-xl" />
|
|
||||||
</span>
|
|
||||||
<h2 class="mt-5 font-display text-xl font-semibold tracking-display text-fg">
|
|
||||||
Arrastra tus archivos aquí
|
|
||||||
</h2>
|
|
||||||
<p class="mt-2 text-xs text-fg-2">
|
|
||||||
WAV, FLAC, AIFF o MP3 · hasta 500 MB por archivo · puedes soltar carpetas enteras
|
|
||||||
</p>
|
|
||||||
<Button class="mt-6" onclick={() => input?.click()}>Elegir archivos</Button>
|
|
||||||
<input
|
|
||||||
bind:this={input}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept={ACCEPT}
|
accept={ACCEPT}
|
||||||
onchange={onPick}
|
title="Arrastra tus archivos aquí"
|
||||||
class="hidden"
|
hint={uploadLimitsLabel()}
|
||||||
aria-label="Elegir archivos de audio"
|
inputLabel="Elegir archivos de audio"
|
||||||
tabindex="-1"
|
onfiles={enqueue}
|
||||||
|
readDrop={filesFromDrop}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<UploadSettings
|
<UploadSettings
|
||||||
{genres}
|
{genres}
|
||||||
@@ -131,7 +89,7 @@
|
|||||||
<section class="min-w-0 rounded-panel bg-surface p-5 sm:p-6">
|
<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 class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="font-display text-xl font-semibold tracking-display text-fg">Cola de subida</h2>
|
<h2 class="text-display-3 text-fg">Cola de subida</h2>
|
||||||
<p class="mt-1 text-xs text-fg-2">
|
<p class="mt-1 text-xs text-fg-2">
|
||||||
{queue.length > 0 ? queueMeta : 'Todavía no hay archivos'}
|
{queue.length > 0 ? queueMeta : 'Todavía no hay archivos'}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<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}
|
||||||
|
stagger
|
||||||
|
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,15 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { LucideIcon } from '@lucide/svelte';
|
import type { LucideIcon } from '@lucide/svelte';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
import { expoOut } from 'svelte/easing';
|
||||||
import type { LibraryTrack } from '$lib/types';
|
import type { LibraryTrack } from '$lib/types';
|
||||||
|
import type { transferBetweenLists } from '$lib/utils/transitions';
|
||||||
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
||||||
|
|
||||||
|
type Transfer = ReturnType<typeof transferBetweenLists>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tracks: LibraryTrack[];
|
tracks: LibraryTrack[];
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
send: Transfer[0];
|
||||||
|
receive: Transfer[1];
|
||||||
onPick: (track: LibraryTrack) => void;
|
onPick: (track: LibraryTrack) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { tracks, icon: Icon, onPick }: Props = $props();
|
let { tracks, icon: Icon, send, receive, onPick }: Props = $props();
|
||||||
|
|
||||||
function subtitle(track: LibraryTrack): string {
|
function subtitle(track: LibraryTrack): string {
|
||||||
const album = track.albumTitle ? `en ${track.albumTitle}` : 'Sin álbum';
|
const album = track.albumTitle ? `en ${track.albumTitle}` : 'Sin álbum';
|
||||||
@@ -19,7 +26,12 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-px" role="list">
|
<div class="flex flex-col gap-px" role="list">
|
||||||
{#each tracks as track (track.id)}
|
{#each tracks as track (track.id)}
|
||||||
<div role="listitem">
|
<div
|
||||||
|
role="listitem"
|
||||||
|
animate:flip={{ duration: 200, easing: expoOut }}
|
||||||
|
in:receive|global={{ key: track.id }}
|
||||||
|
out:send|global={{ key: track.id }}
|
||||||
|
>
|
||||||
<ListRow
|
<ListRow
|
||||||
size="xs"
|
size="xs"
|
||||||
trackId={track.id}
|
trackId={track.id}
|
||||||
|
|||||||
@@ -1,21 +1,42 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
const GRID_COLUMNS = [
|
import { COL, type GridColumn } from '$lib/utils/gridColumns';
|
||||||
'minmax(200px,1fr)',
|
|
||||||
'minmax(96px,220px)',
|
const TEXT = 'truncate text-xs text-fg-2';
|
||||||
'96px',
|
|
||||||
'88px',
|
const COLUMNS: GridColumn[] = [
|
||||||
'72px',
|
{
|
||||||
'140px',
|
id: 'title',
|
||||||
'36px'
|
label: 'Título',
|
||||||
].join(' ');
|
width: 'minmax(12.5rem,1fr)',
|
||||||
const GRID_COLUMNS_MOBILE = ['minmax(120px,1fr)', '96px', '32px'].join(' ');
|
mobile: COL.titleMobile,
|
||||||
const GRID_STYLE = `--mf-track-cols:${GRID_COLUMNS}; --mf-track-cols-mobile:${GRID_COLUMNS_MOBILE}`;
|
mobileLabel: true
|
||||||
|
},
|
||||||
|
{ id: 'genres', label: 'Géneros', width: COL.genresWide, class: TEXT },
|
||||||
|
{ id: 'access', label: 'Acceso', width: COL.access, class: TEXT },
|
||||||
|
{ id: 'size', label: 'Tamaño', width: COL.size, class: `${TEXT} tabular-nums` },
|
||||||
|
{
|
||||||
|
id: 'duration',
|
||||||
|
label: 'Duración',
|
||||||
|
width: COL.durationQueue,
|
||||||
|
align: 'center',
|
||||||
|
class: `${TEXT} tabular-nums`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
label: 'Estado',
|
||||||
|
width: COL.statusQueue,
|
||||||
|
mobile: COL.statusMobile,
|
||||||
|
mobileLabel: true
|
||||||
|
},
|
||||||
|
{ id: 'remove', width: COL.action, mobile: COL.actionMobile }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import { fmtTime, formatSize } from '$lib/utils/format';
|
import { fmtTime, formatSize } from '$lib/utils/format';
|
||||||
import type { QueuedUpload } from '$lib/data/uploadQueue';
|
import type { QueuedUpload } from '$lib/data/uploadQueue';
|
||||||
|
import DataGrid from '$lib/components/ui/media/DataGrid.svelte';
|
||||||
import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
|
import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
|
||||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
|
|
||||||
@@ -36,28 +57,28 @@
|
|||||||
onTitle,
|
onTitle,
|
||||||
class: klass = ''
|
class: klass = ''
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
function details(item: QueuedUpload): string {
|
||||||
|
return [
|
||||||
|
genresLabel,
|
||||||
|
visibilityLabel,
|
||||||
|
formatSize(item.file.size),
|
||||||
|
item.duration === null ? '' : fmtTime(item.duration)
|
||||||
|
]
|
||||||
|
.filter((part) => part !== '' && part !== '-')
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overflow-x-auto {klass}">
|
<DataGrid
|
||||||
<div
|
class={klass}
|
||||||
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-2 sm:gap-6 sm:pr-6 sm:pl-3"
|
columns={COLUMNS}
|
||||||
style={GRID_STYLE}
|
rows={items}
|
||||||
>
|
rowKey={(item) => item.id}
|
||||||
<span>Título</span>
|
rowClass={() => 'hover:bg-hover'}
|
||||||
<span class="hidden sm:block">Géneros</span>
|
>
|
||||||
<span class="hidden sm:block">Acceso</span>
|
{#snippet cell(item, column)}
|
||||||
<span class="hidden sm:block">Tamaño</span>
|
{#if column.id === 'title'}
|
||||||
<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">
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
<span
|
<span
|
||||||
class="grid size-10 shrink-0 place-items-center rounded-control bg-surface text-xs font-medium text-fg-2"
|
class="grid size-10 shrink-0 place-items-center rounded-control bg-surface text-xs font-medium text-fg-2"
|
||||||
@@ -72,27 +93,27 @@
|
|||||||
class="w-full truncate rounded-tag bg-transparent text-sm text-fg transition-colors hover:bg-surface focus:bg-surface-2 focus:outline-none"
|
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>
|
<p class="truncate text-xs text-fg-2">{item.file.name}</p>
|
||||||
|
<p class="truncate text-xs text-fg-2 sm:hidden">{details(item)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="hidden min-w-0 truncate text-xs text-fg-2 sm:block">{genresLabel}</span>
|
{:else if column.id === 'genres'}
|
||||||
<span class="hidden min-w-0 truncate text-xs text-fg-2 sm:block">{visibilityLabel}</span>
|
{genresLabel}
|
||||||
<span class="hidden truncate text-xs text-fg-2 tabular-nums sm:block">
|
{:else if column.id === 'access'}
|
||||||
|
{visibilityLabel}
|
||||||
|
{:else if column.id === 'size'}
|
||||||
{formatSize(item.file.size) || '-'}
|
{formatSize(item.file.size) || '-'}
|
||||||
</span>
|
{:else if column.id === 'duration'}
|
||||||
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
|
|
||||||
{item.duration === null ? '-' : fmtTime(item.duration)}
|
{item.duration === null ? '-' : fmtTime(item.duration)}
|
||||||
</span>
|
{:else if column.id === 'status'}
|
||||||
<span>
|
|
||||||
{#if item.error}
|
{#if item.error}
|
||||||
<StatusBadge label={item.error} tone="danger" />
|
<StatusBadge label={item.error} tone="danger" />
|
||||||
{:else}
|
{:else}
|
||||||
<StatusBadge label="Lista" tone="success" />
|
<StatusBadge label="Lista" tone="success" />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
{:else}
|
||||||
<IconButton label="Quitar de la cola" size="xs" onclick={() => onRemove(item.id)}>
|
<IconButton label="Quitar de la cola" size="xs" onclick={() => onRemove(item.id)}>
|
||||||
<X class="size-icon-sm" />
|
<X class="size-icon-sm" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
{/if}
|
||||||
{/each}
|
{/snippet}
|
||||||
</div>
|
</DataGrid>
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import type { Visibility } from '$lib/data/uploadQueue';
|
||||||
|
|
||||||
|
const VISIBILITY: { value: Visibility; label: string }[] = [
|
||||||
|
{ value: 'private', label: 'Privado' },
|
||||||
|
{ value: 'public', label: 'Público' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ImageIcon from '@lucide/svelte/icons/image';
|
import ImageIcon from '@lucide/svelte/icons/image';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import { genreOptions } from '$lib/data/genres';
|
import type { GenreOption } from '$lib/types';
|
||||||
import type { Visibility } from '$lib/data/uploadQueue';
|
|
||||||
import { LIMITS } from '$lib/validation';
|
import { LIMITS } from '$lib/validation';
|
||||||
import Chip from '$lib/components/ui/primitives/Chip.svelte';
|
import GenrePicker from '$lib/components/music/GenrePicker.svelte';
|
||||||
import Field from '$lib/components/ui/primitives/Field.svelte';
|
import Field from '$lib/components/ui/primitives/Field.svelte';
|
||||||
import SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte';
|
import SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte';
|
||||||
import Input from '$lib/components/ui/primitives/Input.svelte';
|
import Input from '$lib/components/ui/primitives/Input.svelte';
|
||||||
@@ -13,7 +21,7 @@
|
|||||||
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
|
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
genres: { genre: string; incompatibleWith: string[] }[];
|
genres: GenreOption[];
|
||||||
selectedGenres: string[];
|
selectedGenres: string[];
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
coauthors: string[];
|
coauthors: string[];
|
||||||
@@ -32,29 +40,9 @@
|
|||||||
class: klass = ''
|
class: klass = ''
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const VISIBILITY: { value: Visibility; label: string }[] = [
|
|
||||||
{ value: 'private', label: 'Privado' },
|
|
||||||
{ value: 'public', label: 'Público' }
|
|
||||||
];
|
|
||||||
|
|
||||||
let artist = $state('');
|
let artist = $state('');
|
||||||
let hasCover = $state(false);
|
let hasCover = $state(false);
|
||||||
|
|
||||||
const options = $derived(genreOptions(genres));
|
|
||||||
const blocked = $derived(
|
|
||||||
new Set(
|
|
||||||
options
|
|
||||||
.filter((option) => selectedGenres.includes(option.genre))
|
|
||||||
.flatMap((option) => option.incompatibleWith)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
function toggleGenre(genre: string) {
|
|
||||||
selectedGenres = selectedGenres.includes(genre)
|
|
||||||
? selectedGenres.filter((current) => current !== genre)
|
|
||||||
: [...selectedGenres, genre];
|
|
||||||
}
|
|
||||||
|
|
||||||
function addArtist() {
|
function addArtist() {
|
||||||
const name = artist.trim();
|
const name = artist.trim();
|
||||||
if (name === '') return;
|
if (name === '') return;
|
||||||
@@ -71,11 +59,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="flex flex-col gap-6 {klass}">
|
<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>
|
<p class="text-eyebrow text-fg-2">Aplicar a esta subida</p>
|
||||||
|
|
||||||
<div class="grid gap-8 sm:grid-cols-[auto_1fr]">
|
<div class="grid gap-8 sm:grid-cols-[auto_1fr]">
|
||||||
<Field label="Portada">
|
<Field label="Portada">
|
||||||
<div class="w-full max-w-44">
|
<div class="w-full max-w-cover-fill">
|
||||||
<ImageDropzone
|
<ImageDropzone
|
||||||
name="cover"
|
name="cover"
|
||||||
icon={ImageIcon}
|
icon={ImageIcon}
|
||||||
@@ -90,23 +78,7 @@
|
|||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-7">
|
<div class="flex min-w-0 flex-col gap-7">
|
||||||
<Field label="Géneros">
|
<GenrePicker {genres} bind:selected={selectedGenres} />
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{#each options as option (option.genre)}
|
|
||||||
<Chip
|
|
||||||
selected={selectedGenres.includes(option.genre)}
|
|
||||||
disabled={blocked.has(option.genre)}
|
|
||||||
onclick={() => toggleGenre(option.genre)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Chip>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#snippet hint()}
|
|
||||||
<p>Elige varios. Algunos no se pueden combinar.</p>
|
|
||||||
<span class="shrink-0 tabular-nums">{selectedGenres.length} elegidos</span>
|
|
||||||
{/snippet}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Título" for="upload-title">
|
<Field label="Título" for="upload-title">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
const ALBUM_TYPES = [
|
||||||
|
{ value: 'album', label: 'Álbum' },
|
||||||
|
{ value: 'ep', label: 'EP' },
|
||||||
|
{ value: 'single', label: 'Single' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AlbumType = (typeof ALBUM_TYPES)[number]['value'];
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack, type Snippet } from 'svelte';
|
||||||
import Disc3 from '@lucide/svelte/icons/disc-3';
|
import Disc3 from '@lucide/svelte/icons/disc-3';
|
||||||
import CoverForm from './CoverForm.svelte';
|
import CoverForm from './CoverForm.svelte';
|
||||||
import Field from '$lib/components/ui/primitives/Field.svelte';
|
import Field from '$lib/components/ui/primitives/Field.svelte';
|
||||||
import Input from '$lib/components/ui/primitives/Input.svelte';
|
import Input from '$lib/components/ui/primitives/Input.svelte';
|
||||||
|
import SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte';
|
||||||
import Textarea from '$lib/components/ui/primitives/Textarea.svelte';
|
import Textarea from '$lib/components/ui/primitives/Textarea.svelte';
|
||||||
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
|
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
|
||||||
|
|
||||||
@@ -12,12 +23,19 @@
|
|||||||
initialTitle?: string;
|
initialTitle?: string;
|
||||||
initialDescription?: string;
|
initialDescription?: string;
|
||||||
initialReleaseYear?: number;
|
initialReleaseYear?: number;
|
||||||
|
initialType?: AlbumType;
|
||||||
titlePlaceholder?: string;
|
titlePlaceholder?: string;
|
||||||
coverFallbackUrl?: string;
|
coverFallbackUrl?: string;
|
||||||
|
coverHint?: string;
|
||||||
requireCover?: boolean;
|
requireCover?: boolean;
|
||||||
formMessage?: string;
|
formMessage?: string;
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
submittingLabel: string;
|
submittingLabel: string;
|
||||||
|
layout?: 'inline' | 'split';
|
||||||
|
artist?: string;
|
||||||
|
showType?: boolean;
|
||||||
|
aside?: Snippet;
|
||||||
|
footer?: Snippet;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
@@ -27,12 +45,19 @@
|
|||||||
initialTitle = '',
|
initialTitle = '',
|
||||||
initialDescription = '',
|
initialDescription = '',
|
||||||
initialReleaseYear,
|
initialReleaseYear,
|
||||||
|
initialType = 'album',
|
||||||
titlePlaceholder = '',
|
titlePlaceholder = '',
|
||||||
coverFallbackUrl,
|
coverFallbackUrl,
|
||||||
|
coverHint,
|
||||||
requireCover = false,
|
requireCover = false,
|
||||||
formMessage,
|
formMessage,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
submittingLabel,
|
submittingLabel,
|
||||||
|
layout = 'inline',
|
||||||
|
artist,
|
||||||
|
showType = false,
|
||||||
|
aside,
|
||||||
|
footer,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSuccess
|
onSuccess
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -40,6 +65,7 @@
|
|||||||
let title = $state(untrack(() => initialTitle));
|
let title = $state(untrack(() => initialTitle));
|
||||||
let description = $state(untrack(() => initialDescription));
|
let description = $state(untrack(() => initialDescription));
|
||||||
let releaseYear = $state(untrack(() => (initialReleaseYear ? String(initialReleaseYear) : '')));
|
let releaseYear = $state(untrack(() => (initialReleaseYear ? String(initialReleaseYear) : '')));
|
||||||
|
let type = $state<AlbumType>(untrack(() => initialType));
|
||||||
|
|
||||||
const latestYear = new Date().getFullYear() + 1;
|
const latestYear = new Date().getFullYear() + 1;
|
||||||
</script>
|
</script>
|
||||||
@@ -47,12 +73,16 @@
|
|||||||
<CoverForm
|
<CoverForm
|
||||||
{action}
|
{action}
|
||||||
{coverFallbackUrl}
|
{coverFallbackUrl}
|
||||||
|
{coverHint}
|
||||||
{requireCover}
|
{requireCover}
|
||||||
coverIcon={Disc3}
|
coverIcon={Disc3}
|
||||||
{formMessage}
|
{formMessage}
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
{submittingLabel}
|
{submittingLabel}
|
||||||
canSubmit={title.trim() !== ''}
|
canSubmit={title.trim() !== ''}
|
||||||
|
{layout}
|
||||||
|
{aside}
|
||||||
|
{footer}
|
||||||
{onCancel}
|
{onCancel}
|
||||||
{onSuccess}
|
{onSuccess}
|
||||||
>
|
>
|
||||||
@@ -71,6 +101,19 @@
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
{#if artist !== undefined}
|
||||||
|
<Field label="Artista principal" for="album-artist">
|
||||||
|
<Input id="album-artist" value={artist} disabled />
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showType}
|
||||||
|
<Field label="Tipo">
|
||||||
|
<SegmentedControl options={[...ALBUM_TYPES]} bind:value={type} />
|
||||||
|
<input type="hidden" name="type" value={type} />
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Field label="Año de publicación" for="album-year">
|
<Field label="Año de publicación" for="album-year">
|
||||||
<Input
|
<Input
|
||||||
id="album-year"
|
id="album-year"
|
||||||
|
|||||||
@@ -17,10 +17,14 @@
|
|||||||
submittingLabel: string;
|
submittingLabel: string;
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
helperText?: string;
|
helperText?: string;
|
||||||
|
layout?: 'inline' | 'split';
|
||||||
|
coverHint?: string;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
fields: Snippet;
|
fields: Snippet;
|
||||||
extra?: Snippet;
|
extra?: Snippet;
|
||||||
|
aside?: Snippet;
|
||||||
|
footer?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -33,13 +37,19 @@
|
|||||||
submittingLabel,
|
submittingLabel,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
helperText,
|
helperText,
|
||||||
|
layout = 'inline',
|
||||||
|
coverHint,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
fields,
|
fields,
|
||||||
extra
|
extra,
|
||||||
|
aside,
|
||||||
|
footer
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
|
|
||||||
|
const split = $derived(layout === 'split');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@@ -54,8 +64,33 @@
|
|||||||
if (result.type === 'success' || result.type === 'redirect') onSuccess?.();
|
if (result.type === 'success' || result.type === 'redirect') onSuccess?.();
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
class="mt-5 w-full max-w-4xl space-y-5"
|
class={split ? 'mt-6 grid gap-8 lg:grid-cols-[auto_1fr]' : 'mt-5 w-full max-w-4xl space-y-5'}
|
||||||
>
|
>
|
||||||
|
{#if split}
|
||||||
|
<div class="flex w-full flex-col gap-4 lg:w-64">
|
||||||
|
<ImageDropzone
|
||||||
|
name="cover"
|
||||||
|
fallbackUrl={coverFallbackUrl}
|
||||||
|
required={requireCover}
|
||||||
|
icon={coverIcon}
|
||||||
|
gradient
|
||||||
|
size="fill"
|
||||||
|
class="max-w-cover-fill lg:max-w-none"
|
||||||
|
/>
|
||||||
|
{#if coverHint}
|
||||||
|
<p class="text-xs text-fg-2">{coverHint}</p>
|
||||||
|
{/if}
|
||||||
|
{@render fields()}
|
||||||
|
{@render extra?.()}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
{@render aside?.()}
|
||||||
|
<div class="mt-6 space-y-5">
|
||||||
|
{@render feedback()}
|
||||||
|
{@render actions()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
|
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
|
||||||
<Field>
|
<Field>
|
||||||
<ImageDropzone
|
<ImageDropzone
|
||||||
@@ -73,15 +108,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{@render extra?.()}
|
{@render extra?.()}
|
||||||
|
{@render feedback()}
|
||||||
|
{@render actions()}
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#snippet feedback()}
|
||||||
{#if formMessage}
|
{#if formMessage}
|
||||||
<Alert tone="danger">{formMessage}</Alert>
|
<Alert tone="danger">{formMessage}</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet actions()}
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
{#if helperText}
|
{#if helperText}
|
||||||
<p class="text-sm text-fg-2">{helperText}</p>
|
<p class="text-sm text-fg-2">{helperText}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{@render footer?.()}
|
||||||
<div class="ml-auto flex shrink-0 items-center gap-3">
|
<div class="ml-auto flex shrink-0 items-center gap-3">
|
||||||
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
|
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
|
||||||
<Button type="submit" disabled={submitting || !canSubmit}>
|
<Button type="submit" disabled={submitting || !canSubmit}>
|
||||||
@@ -89,4 +132,4 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{/snippet}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import UploadCloud from '@lucide/svelte/icons/cloud-upload';
|
||||||
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accept: string;
|
||||||
|
title: string;
|
||||||
|
hint?: string;
|
||||||
|
buttonLabel?: string;
|
||||||
|
inputLabel?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
onfiles: (files: File[]) => void;
|
||||||
|
readDrop?: (transfer: DataTransfer) => Promise<File[]>;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
accept,
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
buttonLabel = 'Elegir archivos',
|
||||||
|
inputLabel = buttonLabel,
|
||||||
|
multiple = true,
|
||||||
|
onfiles,
|
||||||
|
readDrop = async (transfer) => Array.from(transfer.files),
|
||||||
|
class: klass = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let depth = $state(0);
|
||||||
|
let input = $state<HTMLInputElement>();
|
||||||
|
|
||||||
|
const dragging = $derived(depth > 0);
|
||||||
|
|
||||||
|
function hasFiles(event: DragEvent): boolean {
|
||||||
|
return event.dataTransfer?.types.includes('Files') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnter(event: DragEvent) {
|
||||||
|
if (!hasFiles(event)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
depth += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOver(event: DragEvent) {
|
||||||
|
if (hasFiles(event)) event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeave(event: DragEvent) {
|
||||||
|
if (!hasFiles(event)) return;
|
||||||
|
depth = Math.max(0, depth - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
depth = 0;
|
||||||
|
if (!event.dataTransfer) return;
|
||||||
|
onfiles(await readDrop(event.dataTransfer));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPick(event: Event) {
|
||||||
|
const target = event.currentTarget as HTMLInputElement;
|
||||||
|
onfiles(Array.from(target.files ?? []));
|
||||||
|
target.value = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
ondragenter={onEnter}
|
||||||
|
ondragover={onOver}
|
||||||
|
ondragleave={onLeave}
|
||||||
|
ondrop={onDrop}
|
||||||
|
class="flex min-h-dropzone flex-col items-center justify-center rounded-panel border border-dashed p-6 text-center transition sm:p-10 {dragging
|
||||||
|
? 'scale-101 border-accent/60 bg-accent-tint'
|
||||||
|
: 'border-line-strong bg-surface'} {klass}"
|
||||||
|
>
|
||||||
|
<span class="grid size-14 place-items-center rounded-full bg-surface-2 text-fg-2">
|
||||||
|
<UploadCloud class="size-icon-xl" />
|
||||||
|
</span>
|
||||||
|
<h2 class="mt-5 text-display-3 text-fg">{title}</h2>
|
||||||
|
{#if hint}
|
||||||
|
<p class="mt-2 text-xs text-fg-2">{hint}</p>
|
||||||
|
{/if}
|
||||||
|
<Button class="mt-6" onclick={() => input?.click()}>{buttonLabel}</Button>
|
||||||
|
<input
|
||||||
|
bind:this={input}
|
||||||
|
type="file"
|
||||||
|
{multiple}
|
||||||
|
{accept}
|
||||||
|
onchange={onPick}
|
||||||
|
class="hidden"
|
||||||
|
aria-label={inputLabel}
|
||||||
|
tabindex="-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
const GAP = { md: 'sm:gap-6', lg: 'sm:gap-8' };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts" generics="T">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
import { expoOut } from 'svelte/easing';
|
||||||
|
import { collapse } from '$lib/utils/transitions';
|
||||||
|
import { gridColumns, type GridColumn } from '$lib/utils/gridColumns';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
columns: readonly GridColumn[];
|
||||||
|
rows: readonly T[];
|
||||||
|
rowKey: (row: T) => string;
|
||||||
|
cell: Snippet<[T, GridColumn, number]>;
|
||||||
|
head?: Snippet<[GridColumn]>;
|
||||||
|
rowClass?: (row: T, index: number) => string;
|
||||||
|
oncontextmenu?: (event: MouseEvent, row: T, index: number) => void;
|
||||||
|
gap?: 'md' | 'lg';
|
||||||
|
stagger?: boolean;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
rowKey,
|
||||||
|
cell,
|
||||||
|
head,
|
||||||
|
rowClass,
|
||||||
|
oncontextmenu,
|
||||||
|
gap = 'md',
|
||||||
|
stagger = false,
|
||||||
|
class: klass = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const style = $derived(gridColumns(columns));
|
||||||
|
|
||||||
|
function cellClass(column: GridColumn): string {
|
||||||
|
return [column.mobile ? '' : 'hidden sm:block', column.align === 'center' ? 'text-center' : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto {klass}">
|
||||||
|
<div
|
||||||
|
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-2 sm:pr-6 sm:pl-3 {GAP[
|
||||||
|
gap
|
||||||
|
]}"
|
||||||
|
{style}
|
||||||
|
>
|
||||||
|
{#each columns as column (column.id)}
|
||||||
|
<div class="min-w-0 {cellClass(column)}">
|
||||||
|
{#if head && column.customHead}
|
||||||
|
{@render head(column)}
|
||||||
|
{:else if column.label}
|
||||||
|
<span class={column.mobileLabel ? '' : 'hidden sm:inline'}>{column.label}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex flex-col gap-0.5" role="list">
|
||||||
|
{#each rows as row, i (rowKey(row))}
|
||||||
|
<div
|
||||||
|
role="listitem"
|
||||||
|
animate:flip={{ duration: 200, easing: expoOut }}
|
||||||
|
out:collapse
|
||||||
|
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:pr-6 sm:pl-3 {GAP[
|
||||||
|
gap
|
||||||
|
]} {stagger ? 'animate-enter' : ''} {rowClass?.(row, i) ?? ''}"
|
||||||
|
style="{style}{stagger ? `; --i:${i}` : ''}"
|
||||||
|
oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, row, i) : undefined}
|
||||||
|
>
|
||||||
|
{#each columns as column (column.id)}
|
||||||
|
<div class="min-w-0 {cellClass(column)} {column.class ?? ''}">
|
||||||
|
{@render cell(row, column, i)}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
trackIds?: string[];
|
trackIds?: string[];
|
||||||
onclick?: (event: MouseEvent) => void;
|
|
||||||
onPlay?: (event: MouseEvent) => void;
|
onPlay?: (event: MouseEvent) => void;
|
||||||
art?: Snippet;
|
art?: Snippet;
|
||||||
oncontextmenu?: (event: MouseEvent) => void;
|
oncontextmenu?: (event: MouseEvent) => void;
|
||||||
@@ -22,7 +21,6 @@
|
|||||||
subtitle,
|
subtitle,
|
||||||
src,
|
src,
|
||||||
trackIds = [],
|
trackIds = [],
|
||||||
onclick,
|
|
||||||
onPlay,
|
onPlay,
|
||||||
art,
|
art,
|
||||||
oncontextmenu,
|
oncontextmenu,
|
||||||
@@ -32,7 +30,6 @@
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
{href}
|
{href}
|
||||||
{onclick}
|
|
||||||
class="group animate-enter min-w-0 rounded-panel p-2.5 text-left transition-colors hover:bg-hover"
|
class="group animate-enter min-w-0 rounded-panel p-2.5 text-left transition-colors hover:bg-hover"
|
||||||
style="--i:{index}"
|
style="--i:{index}"
|
||||||
{oncontextmenu}
|
{oncontextmenu}
|
||||||
|
|||||||
@@ -2,20 +2,21 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
label: string;
|
label: string;
|
||||||
tone?: 'success' | 'neutral' | 'danger';
|
tone?: 'success' | 'neutral' | 'danger';
|
||||||
|
pulse?: boolean;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { label, tone = 'neutral', class: klass = '' }: Props = $props();
|
let { label, tone = 'neutral', pulse = false, class: klass = '' }: Props = $props();
|
||||||
|
|
||||||
const TONE = {
|
const TONE = {
|
||||||
success: 'bg-accent-tint text-accent-soft',
|
success: 'bg-accent-tint text-accent-soft',
|
||||||
neutral: 'bg-btn text-fg-2',
|
neutral: 'bg-btn text-fg-2',
|
||||||
danger: 'bg-danger/15 text-danger-fg'
|
danger: 'bg-danger-tint text-danger-fg'
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap {TONE[
|
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap {pulse
|
||||||
tone
|
? 'animate-pulse'
|
||||||
]} {klass}">{label}</span
|
: ''} {TONE[tone]} {klass}">{label}</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
value?: string;
|
value?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
oninput?: () => void;
|
oninput?: () => void;
|
||||||
onclear?: () => void;
|
onclear?: () => void;
|
||||||
class?: string;
|
class?: string;
|
||||||
@@ -15,11 +16,17 @@
|
|||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
placeholder = 'Buscar',
|
placeholder = 'Buscar',
|
||||||
label = placeholder,
|
label = placeholder,
|
||||||
|
size = 'md',
|
||||||
oninput,
|
oninput,
|
||||||
onclear,
|
onclear,
|
||||||
class: klass = ''
|
class: klass = ''
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const SIZE = {
|
||||||
|
sm: { text: 'text-xs', icon: 'size-icon-xs' },
|
||||||
|
md: { text: 'text-sm', icon: 'size-icon-sm' }
|
||||||
|
};
|
||||||
|
|
||||||
let input: HTMLInputElement | undefined = $state();
|
let input: HTMLInputElement | undefined = $state();
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
@@ -32,7 +39,7 @@
|
|||||||
<label
|
<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}"
|
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" />
|
<Search class="{SIZE[size].icon} shrink-0 text-fg-2" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:this={input}
|
bind:this={input}
|
||||||
@@ -40,7 +47,8 @@
|
|||||||
{oninput}
|
{oninput}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
class="w-full min-w-0 bg-transparent text-xs text-fg placeholder:text-fg-2 focus:outline-none"
|
class="w-full min-w-0 bg-transparent {SIZE[size]
|
||||||
|
.text} text-fg placeholder:text-fg-2 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{#if value}
|
{#if value}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import Button from './Button.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
onClear: () => void;
|
||||||
|
class?: string;
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { label, onClear, class: klass = '', children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3 {klass}">
|
||||||
|
<span class="text-sm text-fg-2">{label}</span>
|
||||||
|
{@render children()}
|
||||||
|
<Button size="sm" variant="secondary" onclick={onClear}>Deseleccionar</Button>
|
||||||
|
</div>
|
||||||
@@ -12,16 +12,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { label, tabs, class: klass = '' }: Props = $props();
|
let { label, tabs, class: klass = '' }: Props = $props();
|
||||||
|
|
||||||
|
let links = $state<Record<string, HTMLAnchorElement | undefined>>({});
|
||||||
|
let indicator = $state({ x: 0, width: 0 });
|
||||||
|
let measured = $state(false);
|
||||||
|
let animated = $state(false);
|
||||||
|
|
||||||
|
const activeHref = $derived(tabs.find((tab) => tab.active)?.href);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const link = activeHref ? links[activeHref] : undefined;
|
||||||
|
if (!link) return;
|
||||||
|
indicator = { x: link.offsetLeft, width: link.offsetWidth };
|
||||||
|
measured = true;
|
||||||
|
if (!animated) requestAnimationFrame(() => (animated = true));
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav aria-label={label} class="flex gap-2 {klass}">
|
<nav
|
||||||
|
aria-label={label}
|
||||||
|
class="relative inline-flex max-w-full rounded-full bg-surface-2 p-1 {klass}"
|
||||||
|
>
|
||||||
|
{#if measured}
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-1 left-0 rounded-full bg-fg ease-snappy {animated
|
||||||
|
? 'transition-[transform,width] duration-200'
|
||||||
|
: ''}"
|
||||||
|
style="width:{indicator.width}px; transform:translateX({indicator.x}px)"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
{#each tabs as tab (tab.href)}
|
{#each tabs as tab (tab.href)}
|
||||||
<a
|
<a
|
||||||
|
bind:this={links[tab.href]}
|
||||||
href={tab.href}
|
href={tab.href}
|
||||||
aria-current={tab.active ? 'page' : undefined}
|
aria-current={tab.active ? 'page' : undefined}
|
||||||
class="rounded-full px-4 py-1.5 text-xs font-medium focus-ring transition-colors {tab.active
|
class="relative z-(--z-raised) rounded-full px-4 py-1.5 text-xs font-medium focus-ring transition-colors {tab.active
|
||||||
? 'bg-fg text-ink'
|
? 'text-ink'
|
||||||
: 'bg-surface-2 text-fg-2 hover:text-fg'}"
|
: 'text-fg-2 hover:text-fg'}"
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { findConflict, genreHue } from './genres';
|
import { findConflict, genreHue, genreLabels } from './genres';
|
||||||
|
|
||||||
describe('genreHue', () => {
|
describe('genreHue', () => {
|
||||||
it('is stable and within 0-359', () => {
|
it('is stable and within 0-359', () => {
|
||||||
@@ -26,3 +26,10 @@ describe('findConflict', () => {
|
|||||||
expect(findConflict(['x', 'y'], options)).toBeUndefined();
|
expect(findConflict(['x', 'y'], options)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('genreLabels', () => {
|
||||||
|
it('joins the labels and falls back to a dash', () => {
|
||||||
|
expect(genreLabels(['HipHop', 'Rock'])).toBe('Hip hop, Rock');
|
||||||
|
expect(genreLabels([])).toBe('-');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { GenreOption } from '$lib/types';
|
||||||
|
|
||||||
const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
|
const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
|
||||||
Pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
|
Pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
|
||||||
Rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
|
Rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
|
||||||
@@ -45,6 +47,10 @@ export function genreHue(genre: string): number {
|
|||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function genreLabels(genres: string[]): string {
|
||||||
|
return genres.map((genre) => genreInfo(genre).label).join(', ') || '-';
|
||||||
|
}
|
||||||
|
|
||||||
export function genreTiles(genres: { genre: string }[]) {
|
export function genreTiles(genres: { genre: string }[]) {
|
||||||
return genres.map(({ genre }) => ({
|
return genres.map(({ genre }) => ({
|
||||||
genre,
|
genre,
|
||||||
@@ -53,7 +59,7 @@ export function genreTiles(genres: { genre: string }[]) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function genreOptions(genres: { genre: string; incompatibleWith: string[] }[]) {
|
export function genreOptions(genres: GenreOption[]) {
|
||||||
return genres.map(({ genre, incompatibleWith }) => ({
|
return genres.map(({ genre, incompatibleWith }) => ({
|
||||||
genre,
|
genre,
|
||||||
incompatibleWith,
|
incompatibleWith,
|
||||||
@@ -61,10 +67,7 @@ export function genreOptions(genres: { genre: string; incompatibleWith: string[]
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findConflict(
|
export function findConflict(tags: string[], options: GenreOption[]): [string, string] | undefined {
|
||||||
tags: string[],
|
|
||||||
options: { genre: string; incompatibleWith: string[] }[]
|
|
||||||
): [string, string] | undefined {
|
|
||||||
const byGenre = new Map(options.map((option) => [option.genre, option.incompatibleWith]));
|
const byGenre = new Map(options.map((option) => [option.genre, option.incompatibleWith]));
|
||||||
for (let i = 0; i < tags.length; i++) {
|
for (let i = 0; i < tags.length; i++) {
|
||||||
for (let j = i + 1; j < tags.length; j++) {
|
for (let j = i + 1; j < tags.length; j++) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
titleFromFileName,
|
titleFromFileName,
|
||||||
toQueuedUpload,
|
toQueuedUpload,
|
||||||
uploadError,
|
uploadError,
|
||||||
|
uploadLimitsLabel,
|
||||||
UPLOAD_MAX_BYTES
|
UPLOAD_MAX_BYTES
|
||||||
} from '$lib/data/uploadQueue';
|
} from '$lib/data/uploadQueue';
|
||||||
|
|
||||||
@@ -43,3 +44,11 @@ describe('summarizeQueue', () => {
|
|||||||
expect(summarizeQueue(items)).toEqual({ ready: 2, failed: 1 });
|
expect(summarizeQueue(items)).toEqual({ ready: 2, failed: 1 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('uploadLimitsLabel', () => {
|
||||||
|
it('lists the formats and the size limit', () => {
|
||||||
|
expect(uploadLimitsLabel()).toBe(
|
||||||
|
'WAV, FLAC, AIFF o MP3 · hasta 500 MB por archivo · puedes soltar carpetas enteras'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
export const UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
export const UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
||||||
export const UPLOAD_FORMATS = ['wav', 'flac', 'aiff', 'aif', 'mp3'];
|
export const UPLOAD_FORMATS = ['wav', 'flac', 'aiff', 'aif', 'mp3'];
|
||||||
|
|
||||||
|
const UPLOAD_MAX_MB = Math.round(UPLOAD_MAX_BYTES / (1024 * 1024));
|
||||||
|
|
||||||
|
export function uploadLimitsLabel(): string {
|
||||||
|
const names = UPLOAD_FORMATS.filter((ext) => ext !== 'aif').map((ext) => ext.toUpperCase());
|
||||||
|
const list = `${names.slice(0, -1).join(', ')} o ${names[names.length - 1]}`;
|
||||||
|
return `${list} · hasta ${UPLOAD_MAX_MB} MB por archivo · puedes soltar carpetas enteras`;
|
||||||
|
}
|
||||||
|
|
||||||
export type Visibility = 'private' | 'public';
|
export type Visibility = 'private' | 'public';
|
||||||
|
|
||||||
export interface QueuedUpload {
|
export interface QueuedUpload {
|
||||||
@@ -30,7 +38,7 @@ export function titleFromFileName(name: string): string {
|
|||||||
|
|
||||||
export function uploadError(file: File): string | null {
|
export function uploadError(file: File): string | null {
|
||||||
if (!UPLOAD_FORMATS.includes(fileExtension(file.name))) return 'Formato no compatible';
|
if (!UPLOAD_FORMATS.includes(fileExtension(file.name))) return 'Formato no compatible';
|
||||||
if (file.size > UPLOAD_MAX_BYTES) return 'Supera 500 MB';
|
if (file.size > UPLOAD_MAX_BYTES) return `Supera ${UPLOAD_MAX_MB} MB`;
|
||||||
if (file.size === 0) return 'Archivo vacío';
|
if (file.size === 0) return 'Archivo vacío';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export interface Track {
|
|||||||
listensCount: number;
|
listensCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenreOption {
|
||||||
|
genre: string;
|
||||||
|
incompatibleWith: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export type TrackStatus = 'Pending' | 'Processing' | 'Completed' | 'Failed';
|
export type TrackStatus = 'Pending' | 'Processing' | 'Completed' | 'Failed';
|
||||||
|
|
||||||
export interface LibraryTrack extends Track {
|
export interface LibraryTrack extends Track {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { gridColumns } from './gridColumns';
|
||||||
|
|
||||||
|
describe('gridColumns', () => {
|
||||||
|
it('builds desktop and mobile templates, skipping columns without a mobile width', () => {
|
||||||
|
const style = gridColumns([
|
||||||
|
{ id: 'title', width: '1fr', mobile: '2fr' },
|
||||||
|
{ id: 'album', width: '10rem' },
|
||||||
|
{ id: 'action', width: '2rem', mobile: '1rem' }
|
||||||
|
]);
|
||||||
|
expect(style).toBe('--mf-track-cols:1fr 10rem 2rem; --mf-track-cols-mobile:2fr 1rem');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export interface GridColumn {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
width: string;
|
||||||
|
mobile?: string;
|
||||||
|
align?: 'center';
|
||||||
|
mobileLabel?: boolean;
|
||||||
|
customHead?: boolean;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COL = {
|
||||||
|
check: '1.75rem',
|
||||||
|
index: '2rem',
|
||||||
|
indexMobile: '1.75rem',
|
||||||
|
title: 'minmax(11.25rem,1fr)',
|
||||||
|
titleMobile: 'minmax(7.5rem,1fr)',
|
||||||
|
titleWide: 'minmax(12.5rem,1fr)',
|
||||||
|
action: '2.25rem',
|
||||||
|
actionMobile: '2rem',
|
||||||
|
duration: '5rem',
|
||||||
|
durationWide: '6.5rem',
|
||||||
|
durationMobile: '3.25rem',
|
||||||
|
date: '7.75rem',
|
||||||
|
plays: '6.5rem',
|
||||||
|
album: 'minmax(6rem,10rem)',
|
||||||
|
genres: 'minmax(6rem,12.5rem)',
|
||||||
|
genresWide: 'minmax(6rem,13.75rem)',
|
||||||
|
status: '7rem',
|
||||||
|
statusQueue: '8.75rem',
|
||||||
|
statusMobile: '6rem',
|
||||||
|
access: '6rem',
|
||||||
|
size: '5.5rem',
|
||||||
|
durationQueue: '4.5rem'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function gridColumns(columns: readonly GridColumn[]): string {
|
||||||
|
const desktop = columns.map((column) => column.width).join(' ');
|
||||||
|
const mobile = columns.flatMap((column) => (column.mobile ? [column.mobile] : [])).join(' ');
|
||||||
|
return `--mf-track-cols:${desktop}; --mf-track-cols-mobile:${mobile}`;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expoOut } from 'svelte/easing';
|
import { expoOut } from 'svelte/easing';
|
||||||
import type { TransitionConfig } from 'svelte/transition';
|
import { crossfade, type TransitionConfig } from 'svelte/transition';
|
||||||
|
|
||||||
export function pop(
|
export function pop(
|
||||||
node: Element,
|
node: Element,
|
||||||
@@ -12,3 +12,23 @@ export function pop(
|
|||||||
`opacity: ${t}; transform: scale(${0.98 + 0.02 * t}) translateY(${0.25 * (1 - t)}rem);`
|
`opacity: ${t}; transform: scale(${0.98 + 0.02 * t}) translateY(${0.25 * (1 - t)}rem);`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function collapse(
|
||||||
|
node: Element,
|
||||||
|
{ duration = 200 }: { duration?: number } = {}
|
||||||
|
): TransitionConfig {
|
||||||
|
const height = node.getBoundingClientRect().height;
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
const paddingTop = parseFloat(style.paddingTop);
|
||||||
|
const paddingBottom = parseFloat(style.paddingBottom);
|
||||||
|
return {
|
||||||
|
duration,
|
||||||
|
easing: expoOut,
|
||||||
|
css: (t) =>
|
||||||
|
`overflow: hidden; opacity: ${t}; height: ${height * t}px; padding-top: ${paddingTop * t}px; padding-bottom: ${paddingBottom * t}px;`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transferBetweenLists() {
|
||||||
|
return crossfade({ duration: 200, easing: expoOut });
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@
|
|||||||
|
|
||||||
let scroller = $state<HTMLElement>();
|
let scroller = $state<HTMLElement>();
|
||||||
|
|
||||||
|
const transitionKey = $derived(
|
||||||
|
page.url.pathname === '/library' || page.url.pathname.startsWith('/library/')
|
||||||
|
? '/library'
|
||||||
|
: page.url.pathname
|
||||||
|
);
|
||||||
|
|
||||||
trackNavigation();
|
trackNavigation();
|
||||||
restoreScroll(() => scroller);
|
restoreScroll(() => scroller);
|
||||||
|
|
||||||
@@ -81,7 +87,7 @@
|
|||||||
<MobileHeader user={data.user} accountUrl={data.accountUrl} />
|
<MobileHeader user={data.user} accountUrl={data.accountUrl} />
|
||||||
<TopBar user={data.user} accountUrl={data.accountUrl} />
|
<TopBar user={data.user} accountUrl={data.accountUrl} />
|
||||||
<main class="flex-1">
|
<main class="flex-1">
|
||||||
{#key page.url.pathname}
|
{#key transitionKey}
|
||||||
<div class="animate-fade">{@render children()}</div>
|
<div class="animate-fade">{@render children()}</div>
|
||||||
{/key}
|
{/key}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -47,5 +47,7 @@
|
|||||||
<Page>
|
<Page>
|
||||||
<PageHeader title="Gestionar música" description={current.description} />
|
<PageHeader title="Gestionar música" description={current.description} />
|
||||||
<Tabs label="Secciones de la biblioteca" {tabs} />
|
<Tabs label="Secciones de la biblioteca" {tabs} />
|
||||||
{@render children()}
|
{#key page.url.pathname}
|
||||||
|
<div class="animate-fade">{@render children()}</div>
|
||||||
|
{/key}
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
import LibraryAlbumEditor from '$lib/components/music/library/LibraryAlbumEditor.svelte';
|
import LibraryAlbumEditor from '$lib/components/music/library/LibraryAlbumEditor.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="animate-enter mt-4">
|
||||||
{#key data.album.id}
|
{#key data.album.id}
|
||||||
<LibraryAlbumEditor
|
<LibraryAlbumEditor
|
||||||
album={data.album}
|
album={data.album}
|
||||||
|
artist={page.data.user?.name ?? ''}
|
||||||
tracks={data.tracks.items}
|
tracks={data.tracks.items}
|
||||||
memberIds={data.memberIds}
|
memberIds={data.memberIds}
|
||||||
formMessage={form?.message}
|
formMessage={form?.message}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
import LibraryAlbumEditor from '$lib/components/music/library/LibraryAlbumEditor.svelte';
|
import LibraryAlbumEditor from '$lib/components/music/library/LibraryAlbumEditor.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
@@ -9,9 +10,10 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="animate-enter mt-4">
|
||||||
<LibraryAlbumEditor
|
<LibraryAlbumEditor
|
||||||
album={null}
|
album={null}
|
||||||
|
artist={page.data.user?.name ?? ''}
|
||||||
tracks={data.tracks.items}
|
tracks={data.tracks.items}
|
||||||
memberIds={[]}
|
memberIds={[]}
|
||||||
formMessage={form?.action === 'saveAlbum' ? form.message : undefined}
|
formMessage={form?.action === 'saveAlbum' ? form.message : undefined}
|
||||||
|
|||||||
@@ -14,10 +14,9 @@
|
|||||||
import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte';
|
import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte';
|
||||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
|
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
|
||||||
import Chip from '$lib/components/ui/primitives/Chip.svelte';
|
|
||||||
import { LIMITS } from '$lib/validation';
|
import { LIMITS } from '$lib/validation';
|
||||||
import { formatSize } from '$lib/utils/format';
|
import { formatSize } from '$lib/utils/format';
|
||||||
import { genreOptions } from '$lib/data/genres';
|
import GenrePicker from '$lib/components/music/GenrePicker.svelte';
|
||||||
|
|
||||||
type Status = 'idle' | 'uploading' | 'done' | 'error';
|
type Status = 'idle' | 'uploading' | 'done' | 'error';
|
||||||
type StepState = 'idle' | 'active' | 'done';
|
type StepState = 'idle' | 'active' | 'done';
|
||||||
@@ -44,15 +43,6 @@
|
|||||||
{ key: 'done', label: 'Listo', hint: 'Se procesa y aparece en tu biblioteca.' }
|
{ key: 'done', label: 'Listo', hint: 'Se procesa y aparece en tu biblioteca.' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const options = $derived(genreOptions(data.genres));
|
|
||||||
const blocked = $derived(
|
|
||||||
new Set(
|
|
||||||
options
|
|
||||||
.filter((option) => tags.includes(option.genre))
|
|
||||||
.flatMap((option) => option.incompatibleWith)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const busy = $derived(status === 'uploading');
|
const busy = $derived(status === 'uploading');
|
||||||
|
|
||||||
const canPublish = $derived(
|
const canPublish = $derived(
|
||||||
@@ -91,10 +81,6 @@
|
|||||||
pickAudio(file);
|
pickAudio(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTag(tag: string) {
|
|
||||||
tags = tags.includes(tag) ? tags.filter((current) => current !== tag) : [...tags, tag];
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
title = '';
|
title = '';
|
||||||
audioName = '';
|
audioName = '';
|
||||||
@@ -258,28 +244,12 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Géneros">
|
<GenrePicker
|
||||||
<div class="mb-2 flex flex-wrap gap-2">
|
genres={data.genres}
|
||||||
{#each options as option (option.genre)}
|
bind:selected={tags}
|
||||||
<Chip
|
name="tags"
|
||||||
selected={tags.includes(option.genre)}
|
help="Elige al menos uno. Algunos géneros no se pueden combinar, como Metal con Ambient."
|
||||||
disabled={blocked.has(option.genre)}
|
/>
|
||||||
onclick={() => toggleTag(option.genre)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Chip>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#each tags as tag (tag)}
|
|
||||||
<input type="hidden" name="tags" value={tag} />
|
|
||||||
{/each}
|
|
||||||
{#snippet hint()}
|
|
||||||
<p>
|
|
||||||
Elige al menos uno. Algunos géneros no se pueden combinar, como Metal con Ambient.
|
|
||||||
</p>
|
|
||||||
<span class="shrink-0 tabular-nums">{tags.length} elegidos</span>
|
|
||||||
{/snippet}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Checkbox bind:checked={isExplicit} name="isExplicit">
|
<Checkbox bind:checked={isExplicit} name="isExplicit">
|
||||||
<span class="inline-flex items-center gap-2">
|
<span class="inline-flex items-center gap-2">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
--color-on-art-2: var(--mf-on-art-2);
|
--color-on-art-2: var(--mf-on-art-2);
|
||||||
--color-danger: oklch(63.7% 0.15 25.331);
|
--color-danger: oklch(63.7% 0.15 25.331);
|
||||||
--color-danger-fg: var(--mf-danger-fg);
|
--color-danger-fg: var(--mf-danger-fg);
|
||||||
|
--color-danger-tint: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||||
--color-btn: var(--mf-btn-bg);
|
--color-btn: var(--mf-btn-bg);
|
||||||
--color-btn-hover: var(--mf-btn-bg-hover);
|
--color-btn-hover: var(--mf-btn-bg-hover);
|
||||||
--color-cta-strong-hover: var(--mf-cta-strong-hover);
|
--color-cta-strong-hover: var(--mf-cta-strong-hover);
|
||||||
@@ -60,6 +61,8 @@
|
|||||||
--spacing-icon-2xl: 1.75rem;
|
--spacing-icon-2xl: 1.75rem;
|
||||||
--spacing-icon-3xl: 2.5rem;
|
--spacing-icon-3xl: 2.5rem;
|
||||||
--spacing-section: 2.875rem;
|
--spacing-section: 2.875rem;
|
||||||
|
--spacing-dropzone: 16rem;
|
||||||
|
--spacing-cover-fill: 11rem;
|
||||||
|
|
||||||
--container-prose-sm: 26rem;
|
--container-prose-sm: 26rem;
|
||||||
--container-hero: 34rem;
|
--container-hero: 34rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user