Upload from the library queue straight to storage and retire the upload page
This commit is contained in:
@@ -108,6 +108,6 @@ Server helpers:
|
|||||||
- `guards.ts` holds `requireUser`, `optionalUser` and `loginRedirect`. Keep them in pages and actions even though the `(app)` layout also redirects: page loads run in parallel with the layout load, and form actions skip it.
|
- `guards.ts` holds `requireUser`, `optionalUser` and `loginRedirect`. Keep them in pages and actions even though the `(app)` layout also redirects: page loads run in parallel with the layout load, and form actions skip it.
|
||||||
- `mappers.ts` (`toTrack`, `toAlbum`, `toPlaylist`, `toMix`, `toPage`, `toCount`, `toListeningStats`, ...) turn DTOs into the domain types. Every load and `/api` endpoint goes through them, so `Number()` conversions on DTOs never reach routes or the client.
|
- `mappers.ts` (`toTrack`, `toAlbum`, `toPlaylist`, `toMix`, `toPage`, `toCount`, `toListeningStats`, ...) turn DTOs into the domain types. Every load and `/api` endpoint goes through them, so `Number()` conversions on DTOs never reach routes or the client.
|
||||||
- `actions/` holds `authedAction(handler)` (returns 401 without a token, reads the form and hands `{ api, form, params, locals, url }` to the handler) plus the shared `follow` and `playlist` actions.
|
- `actions/` holds `authedAction(handler)` (returns 401 without a token, reads the form and hands `{ api, form, params, locals, url }` to the handler) plus the shared `follow` and `playlist` actions.
|
||||||
- `forms/` holds `fields.ts` (`formString`, `formFile`) and the form parsers (`parseAlbumForm`, `parsePlaylistForm`, `parseTrackUploadForm`); they validate with `LIMITS` from `$lib/validation`. `uploadOptionalCover` handles the optional cover upload.
|
- `forms/` holds `fields.ts` (`formString`, `formFile`) and the form parsers (`parseAlbumForm`, `parsePlaylistForm`); they validate with `LIMITS` from `$lib/validation`. `uploadOptionalCover` handles the optional cover upload.
|
||||||
- `genres.ts` caches the genre list; `playbackConfig.ts` caches the anonymous listening setting.
|
- `genres.ts` caches the genre list; `playbackConfig.ts` caches the anonymous listening setting.
|
||||||
- Page sizes come from `$lib/config`; do not hardcode them.
|
- Page sizes come from `$lib/config`; do not hardcode them.
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ src/routes/
|
|||||||
new/ /library/albums/new Create an album
|
new/ /library/albums/new Create an album
|
||||||
[id]/ /library/albums/:id Edit an album
|
[id]/ /library/albums/:id Edit an album
|
||||||
uploads/ /library/uploads Upload queue
|
uploads/ /library/uploads Upload queue
|
||||||
upload/ /upload Upload a song
|
|
||||||
albums/ /albums Your albums
|
albums/ /albums Your albums
|
||||||
[id]/ /albums/:id Album detail
|
[id]/ /albums/:id Album detail
|
||||||
playlists/ /playlists Your playlists
|
playlists/ /playlists Your playlists
|
||||||
@@ -36,8 +35,7 @@ Global chrome on every app page: sidebar (desktop) or tab bar (mobile), top bar
|
|||||||
- **Home `/`**: a greeting, weekly listening stats, "continue listening" (recently played merged with the in-session history), the featured playlist with its first tracks (play and shuffle load the full list on demand), the user's mixes and playlists. Anonymous visitors are redirected to `/explore`.
|
- **Home `/`**: a greeting, weekly listening stats, "continue listening" (recently played merged with the in-session history), the featured playlist with its first tracks (play and shuffle load the full list on demand), the user's mixes and playlists. Anonymous visitors are redirected to `/explore`.
|
||||||
- **Explore `/explore`**: genre tiles when there is no query, otherwise search results grouped by songs, albums, users and playlists with filter chips and counts taken from the API totals. Songs paginate with infinite scroll. Track and album context menus offer play next, add to queue and add to playlist.
|
- **Explore `/explore`**: genre tiles when there is no query, otherwise search results grouped by songs, albums, users and playlists with filter chips and counts taken from the API totals. Songs paginate with infinite scroll. Track and album context menus offer play next, add to queue and add to playlist.
|
||||||
- **Liked `/liked`**: the songs the user liked, newest first, with unlike per row.
|
- **Liked `/liked`**: the songs the user liked, newest first, with unlike per row.
|
||||||
- **Library `/library`, `/library/albums`, `/library/uploads`**: the manage-music area, one route per section behind shared tabs. Songs lists the user's uploads with a confirmed delete; Albums lists the user's albums and edits them at `/library/albums/new` and `/library/albums/:id`; Uploads is the upload queue.
|
- **Library `/library`, `/library/albums`, `/library/uploads`**: the manage-music area, one route per section behind shared tabs. Songs lists the user's uploads with a confirmed delete; Albums lists the user's albums and edits them at `/library/albums/new` and `/library/albums/:id`; Uploads is the upload queue: the browser sends each file straight to storage through presigned URLs (`/api/uploads`, with progress), `/api/uploads/complete` creates the track unpublished and optionally adds it to an album, and `/api/uploads/status` follows the processing. Genres and cover apply to the whole queue and the queue survives tab changes. `/upload` redirects here.
|
||||||
- **Upload `/upload`**: audio, cover, title, genres (incompatible genres are blocked) and explicit flag. The server reserves presigned URLs, uploads both files and creates the track; processing happens asynchronously in the backend.
|
|
||||||
- **Albums `/albums`, `/albums/:id`**: list and create albums. The owner can edit, delete and add or remove tracks from their library.
|
- **Albums `/albums`, `/albums/:id`**: list and create albums. The owner can edit, delete and add or remove tracks from their library.
|
||||||
- **Playlists `/playlists`, `/playlists/:id`**: list and create playlists. The owner can edit, delete and remove tracks; everyone else gets a read only view.
|
- **Playlists `/playlists`, `/playlists/:id`**: list and create playlists. The owner can edit, delete and remove tracks; everyone else gets a read only view.
|
||||||
- **Mix `/mixes/:id`**: a generated mix with play, shuffle and a track menu.
|
- **Mix `/mixes/:id`**: a generated mix with play, shuffle and a track menu.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const ARBITRARY_WHITELIST = [
|
|||||||
/^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
|
/^transition-\[transform,width\]$/, // Tabs: indicador deslizante
|
||||||
|
/^transition-\[width\]$/, // cola de subida: barra de progreso
|
||||||
/^bg-\[image:var\(--mf-[\w-]+\)\]$/
|
/^bg-\[image:var\(--mf-[\w-]+\)\]$/
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +1,42 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { genreLabels } from '$lib/data/genres';
|
import { genreLabels } from '$lib/data/genres';
|
||||||
import {
|
import { filesFromDrop, UPLOAD_FORMATS, uploadLimitsLabel } from '$lib/data/uploadQueue';
|
||||||
filesFromDrop,
|
import { uploadQueue as queue } from '$lib/state/uploadQueue.svelte';
|
||||||
readDuration,
|
|
||||||
summarizeQueue,
|
|
||||||
toQueuedUpload,
|
|
||||||
UPLOAD_FORMATS,
|
|
||||||
uploadLimitsLabel,
|
|
||||||
type QueuedUpload,
|
|
||||||
type Visibility
|
|
||||||
} from '$lib/data/uploadQueue';
|
|
||||||
import { plural } from '$lib/utils/format';
|
import { plural } from '$lib/utils/format';
|
||||||
import type { GenreOption } from '$lib/types';
|
import type { GenreOption } from '$lib/types';
|
||||||
import FileDropzone from '$lib/components/ui/forms/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/ui/forms/FileDropzone.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 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 AlbumChoice {
|
||||||
genres: GenreOption[];
|
id: string;
|
||||||
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { genres }: Props = $props();
|
interface Props {
|
||||||
|
genres: GenreOption[];
|
||||||
|
albums: AlbumChoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { genres, albums }: Props = $props();
|
||||||
|
|
||||||
const ACCEPT = UPLOAD_FORMATS.map((ext) => `.${ext}`).join(',');
|
const ACCEPT = UPLOAD_FORMATS.map((ext) => `.${ext}`).join(',');
|
||||||
|
|
||||||
let queue = $state<QueuedUpload[]>([]);
|
const summary = $derived(queue.summary);
|
||||||
let selectedGenres = $state<string[]>([]);
|
const genresLabel = $derived(genreLabels(queue.selectedGenres));
|
||||||
let visibility = $state<Visibility>('private');
|
|
||||||
let coauthors = $state<string[]>([]);
|
|
||||||
|
|
||||||
const single = $derived(queue.length === 1 ? queue[0] : null);
|
|
||||||
const summary = $derived(summarizeQueue(queue));
|
|
||||||
const genresLabel = $derived(genreLabels(selectedGenres));
|
|
||||||
const visibilityLabel = $derived(visibility === 'private' ? 'Privado' : 'Público');
|
|
||||||
const queueMeta = $derived(
|
const queueMeta = $derived(
|
||||||
[
|
[
|
||||||
plural(summary.ready, 'lista', 'listas'),
|
summary.ready > 0 ? plural(summary.ready, 'lista', 'listas') : null,
|
||||||
summary.failed > 0 ? `${summary.failed} con error` : null
|
summary.uploading > 0 ? `${summary.uploading} subiendo` : null,
|
||||||
|
summary.processing > 0 ? `${summary.processing} procesando` : null,
|
||||||
|
summary.done > 0 ? plural(summary.done, 'terminada', 'terminadas') : null,
|
||||||
|
summary.errored + summary.failed > 0 ? `${summary.errored + summary.failed} con error` : null
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')
|
.join(' · ')
|
||||||
);
|
);
|
||||||
|
|
||||||
function enqueue(files: File[]) {
|
|
||||||
const added = files.map(toQueuedUpload);
|
|
||||||
queue = [...queue, ...added];
|
|
||||||
for (const item of added) {
|
|
||||||
if (item.error) continue;
|
|
||||||
readDuration(item.file).then((duration) => {
|
|
||||||
queue = queue.map((current) =>
|
|
||||||
current.id === item.id ? { ...current, duration } : current
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setTitle(id: string, title: string) {
|
|
||||||
queue = queue.map((item) => (item.id === id ? { ...item, title } : item));
|
|
||||||
}
|
|
||||||
|
|
||||||
function remove(id: string) {
|
|
||||||
queue = queue.filter((item) => item.id !== id);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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">
|
||||||
@@ -72,17 +46,18 @@
|
|||||||
title="Arrastra tus archivos aquí"
|
title="Arrastra tus archivos aquí"
|
||||||
hint={uploadLimitsLabel()}
|
hint={uploadLimitsLabel()}
|
||||||
inputLabel="Elegir archivos de audio"
|
inputLabel="Elegir archivos de audio"
|
||||||
onfiles={enqueue}
|
onfiles={(files) => queue.enqueue(files)}
|
||||||
readDrop={filesFromDrop}
|
readDrop={filesFromDrop}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UploadSettings
|
<UploadSettings
|
||||||
{genres}
|
{genres}
|
||||||
bind:selectedGenres
|
{albums}
|
||||||
bind:visibility
|
bind:selectedGenres={queue.selectedGenres}
|
||||||
bind:coauthors
|
bind:albumId={queue.albumId}
|
||||||
bind:title={() => single?.title ?? '', (value) => single && setTitle(single.id, value)}
|
hasCover={queue.cover !== null}
|
||||||
titleDisabled={single === null}
|
oncover={(file) => (queue.cover = file)}
|
||||||
|
disabled={queue.running}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -91,24 +66,40 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-display-3 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.items.length > 0 ? queueMeta : 'Todavía no hay archivos'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{#if queue.length > 0}
|
{#if queue.items.length > 0}
|
||||||
<Button variant="secondary" size="sm" onclick={() => (queue = [])}>Vaciar cola</Button>
|
<Button variant="secondary" size="sm" onclick={() => queue.clear()}>Vaciar cola</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<Button disabled>Añadir {summary.ready} a la biblioteca</Button>
|
<Button
|
||||||
|
disabled={summary.ready === 0}
|
||||||
|
loading={queue.running}
|
||||||
|
onclick={() => queue.start(genres)}
|
||||||
|
>
|
||||||
|
Subir {summary.ready}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if queue.length > 0}
|
|
||||||
|
<p class="mt-3 text-xs text-fg-2">
|
||||||
|
Se añadirá sin publicar: publícala desde Canciones cuando termine de procesarse.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if queue.notice}
|
||||||
|
<Alert tone="danger" class="mt-4">{queue.notice}</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if queue.items.length > 0}
|
||||||
<UploadQueueList
|
<UploadQueueList
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
items={queue}
|
items={queue.items}
|
||||||
{genresLabel}
|
{genresLabel}
|
||||||
{visibilityLabel}
|
onRemove={(id) => queue.remove(id)}
|
||||||
onRemove={remove}
|
onCancel={(id) => queue.cancel(id)}
|
||||||
onTitle={setTitle}
|
onRetry={(id) => queue.retry(id)}
|
||||||
|
onTitle={(id, title) => queue.setTitle(id, title)}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="mt-8 mb-6 text-center text-sm text-fg-2">
|
<p class="mt-8 mb-6 text-center text-sm text-fg-2">
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
mobileLabel: true
|
mobileLabel: true
|
||||||
},
|
},
|
||||||
{ id: 'genres', label: 'Géneros', width: COL.genresWide, class: TEXT },
|
{ 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: 'size', label: 'Tamaño', width: COL.size, class: `${TEXT} tabular-nums` },
|
||||||
{
|
{
|
||||||
id: 'duration',
|
id: 'duration',
|
||||||
@@ -28,14 +27,16 @@
|
|||||||
mobile: COL.statusMobile,
|
mobile: COL.statusMobile,
|
||||||
mobileLabel: true
|
mobileLabel: true
|
||||||
},
|
},
|
||||||
{ id: 'remove', width: COL.action, mobile: COL.actionMobile }
|
{ id: 'actions', width: COL.actions, mobile: COL.actionsMobile }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Check from '@lucide/svelte/icons/check';
|
||||||
|
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
|
||||||
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 { isBusy, type QueuedUpload } from '$lib/data/uploadQueue';
|
||||||
import DataGrid from '$lib/components/ui/media/DataGrid.svelte';
|
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';
|
||||||
@@ -43,8 +44,9 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
items: QueuedUpload[];
|
items: QueuedUpload[];
|
||||||
genresLabel: string;
|
genresLabel: string;
|
||||||
visibilityLabel: string;
|
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
|
onCancel: (id: string) => void;
|
||||||
|
onRetry: (id: string) => void;
|
||||||
onTitle: (id: string, title: string) => void;
|
onTitle: (id: string, title: string) => void;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
@@ -52,16 +54,20 @@
|
|||||||
let {
|
let {
|
||||||
items,
|
items,
|
||||||
genresLabel,
|
genresLabel,
|
||||||
visibilityLabel,
|
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onCancel,
|
||||||
|
onRetry,
|
||||||
onTitle,
|
onTitle,
|
||||||
class: klass = ''
|
class: klass = ''
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
function editable(item: QueuedUpload): boolean {
|
||||||
|
return item.phase === 'queued' || item.phase === 'error';
|
||||||
|
}
|
||||||
|
|
||||||
function details(item: QueuedUpload): string {
|
function details(item: QueuedUpload): string {
|
||||||
return [
|
return [
|
||||||
genresLabel,
|
genresLabel,
|
||||||
visibilityLabel,
|
|
||||||
formatSize(item.file.size),
|
formatSize(item.file.size),
|
||||||
item.duration === null ? '' : fmtTime(item.duration)
|
item.duration === null ? '' : fmtTime(item.duration)
|
||||||
]
|
]
|
||||||
@@ -89,17 +95,16 @@
|
|||||||
<input
|
<input
|
||||||
value={item.title}
|
value={item.title}
|
||||||
oninput={(event) => onTitle(item.id, event.currentTarget.value)}
|
oninput={(event) => onTitle(item.id, event.currentTarget.value)}
|
||||||
|
disabled={!editable(item)}
|
||||||
aria-label="Título de {item.file.name}"
|
aria-label="Título de {item.file.name}"
|
||||||
class="w-full truncate rounded-tag bg-transparent text-sm text-fg transition-colors hover:bg-surface focus:bg-surface-2 focus:outline-none"
|
class="w-full truncate rounded-tag bg-transparent text-sm text-fg transition-colors hover:bg-surface focus:bg-surface-2 focus:outline-none disabled:hover:bg-transparent"
|
||||||
/>
|
/>
|
||||||
<p class="truncate text-xs text-fg-2">{item.file.name}</p>
|
<p class="truncate text-xs text-fg-2">{item.note ?? item.file.name}</p>
|
||||||
<p class="truncate text-xs text-fg-2 sm:hidden">{details(item)}</p>
|
<p class="truncate text-xs text-fg-2 sm:hidden">{details(item)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if column.id === 'genres'}
|
{:else if column.id === 'genres'}
|
||||||
{genresLabel}
|
{genresLabel}
|
||||||
{:else if column.id === 'access'}
|
|
||||||
{visibilityLabel}
|
|
||||||
{:else if column.id === 'size'}
|
{:else if column.id === 'size'}
|
||||||
{formatSize(item.file.size) || '-'}
|
{formatSize(item.file.size) || '-'}
|
||||||
{:else if column.id === 'duration'}
|
{:else if column.id === 'duration'}
|
||||||
@@ -107,13 +112,58 @@
|
|||||||
{:else if column.id === 'status'}
|
{:else if column.id === 'status'}
|
||||||
{#if item.error}
|
{#if item.error}
|
||||||
<StatusBadge label={item.error} tone="danger" />
|
<StatusBadge label={item.error} tone="danger" />
|
||||||
|
{:else if item.phase === 'uploading'}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Subiendo {item.file.name}"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={Math.round(item.progress * 100)}
|
||||||
|
>
|
||||||
|
<div class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-btn">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-accent transition-[width]"
|
||||||
|
style="width:{item.progress * 100}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="w-9 text-right text-xs text-fg-2 tabular-nums">
|
||||||
|
{Math.round(item.progress * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{:else if item.phase === 'creating'}
|
||||||
|
<StatusBadge label="Creando" />
|
||||||
|
{:else if item.phase === 'processing'}
|
||||||
|
<StatusBadge label="Procesando" tone="neutral" pulse />
|
||||||
|
{:else if item.phase === 'done'}
|
||||||
|
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-accent-soft">
|
||||||
|
<Check class="animate-pop size-icon-sm" />
|
||||||
|
Lista
|
||||||
|
</span>
|
||||||
|
{:else if item.phase === 'error'}
|
||||||
|
<StatusBadge label={item.failure ?? 'Error'} tone="danger" />
|
||||||
{:else}
|
{:else}
|
||||||
<StatusBadge label="Lista" tone="success" />
|
<StatusBadge label="En cola" />
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<IconButton label="Quitar de la cola" size="xs" onclick={() => onRemove(item.id)}>
|
<div class="flex items-center justify-end">
|
||||||
<X class="size-icon-sm" />
|
{#if isBusy(item)}
|
||||||
</IconButton>
|
<IconButton label="Cancelar subida" size="xs" onclick={() => onCancel(item.id)}>
|
||||||
|
<X class="size-icon-sm" />
|
||||||
|
</IconButton>
|
||||||
|
{:else}
|
||||||
|
{#if item.phase === 'error' && !item.error}
|
||||||
|
<IconButton label="Reintentar" size="xs" onclick={() => onRetry(item.id)}>
|
||||||
|
<RotateCcw class="size-icon-sm" />
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
{#if item.phase !== 'processing'}
|
||||||
|
<IconButton label="Quitar de la cola" size="xs" onclick={() => onRemove(item.id)}>
|
||||||
|
<X class="size-icon-sm" />
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
|
|||||||
@@ -1,139 +1,68 @@
|
|||||||
<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 X from '@lucide/svelte/icons/x';
|
|
||||||
import type { GenreOption } from '$lib/types';
|
import type { GenreOption } from '$lib/types';
|
||||||
import { LIMITS } from '$lib/validation';
|
|
||||||
import GenrePicker from '$lib/components/music/GenrePicker.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 Input from '$lib/components/ui/primitives/Input.svelte';
|
|
||||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
|
||||||
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
|
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
|
||||||
|
|
||||||
|
interface AlbumChoice {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
genres: GenreOption[];
|
genres: GenreOption[];
|
||||||
|
albums: AlbumChoice[];
|
||||||
selectedGenres: string[];
|
selectedGenres: string[];
|
||||||
visibility: Visibility;
|
albumId: string;
|
||||||
coauthors: string[];
|
hasCover: boolean;
|
||||||
title: string;
|
oncover: (file: File) => void;
|
||||||
titleDisabled: boolean;
|
disabled?: boolean;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
genres,
|
genres,
|
||||||
|
albums,
|
||||||
selectedGenres = $bindable(),
|
selectedGenres = $bindable(),
|
||||||
visibility = $bindable(),
|
albumId = $bindable(),
|
||||||
coauthors = $bindable(),
|
hasCover,
|
||||||
title = $bindable(),
|
oncover,
|
||||||
titleDisabled,
|
disabled = false,
|
||||||
class: klass = ''
|
class: klass = ''
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let artist = $state('');
|
|
||||||
let hasCover = $state(false);
|
|
||||||
|
|
||||||
function addArtist() {
|
|
||||||
const name = artist.trim();
|
|
||||||
if (name === '') return;
|
|
||||||
const known = coauthors.some((current) => current.toLowerCase() === name.toLowerCase());
|
|
||||||
if (!known) coauthors = [...coauthors, name];
|
|
||||||
artist = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function onArtistKey(event: KeyboardEvent) {
|
|
||||||
if (event.key !== 'Enter') return;
|
|
||||||
event.preventDefault();
|
|
||||||
addArtist();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="flex flex-col gap-6 {klass}">
|
<aside class="flex flex-col gap-6 {klass}">
|
||||||
<p class="text-eyebrow text-fg-2">Aplicar a esta subida</p>
|
<p class="text-eyebrow text-fg-2">Aplicar a toda la cola</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-cover-fill">
|
<div class="w-full max-w-cover-fill">
|
||||||
<ImageDropzone
|
<ImageDropzone name="cover" icon={ImageIcon} gradient size="fill" onselect={oncover} />
|
||||||
name="cover"
|
|
||||||
icon={ImageIcon}
|
|
||||||
gradient
|
|
||||||
size="fill"
|
|
||||||
onselect={() => (hasCover = true)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{#snippet hint()}
|
{#snippet hint()}
|
||||||
<p>{hasCover ? 'Portada lista' : 'Haz clic para elegir una imagen'}</p>
|
<p>{hasCover ? 'Portada lista' : 'Opcional. Haz clic para elegir una imagen'}</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-7">
|
<div class="flex min-w-0 flex-col gap-7">
|
||||||
<GenrePicker {genres} bind:selected={selectedGenres} />
|
<GenrePicker {genres} bind:selected={selectedGenres} />
|
||||||
|
|
||||||
<Field label="Título" for="upload-title">
|
<Field label="Añadir a un álbum" for="upload-album">
|
||||||
<Input
|
<select id="upload-album" class="field-control px-4" bind:value={albumId} {disabled}>
|
||||||
id="upload-title"
|
<option value="">Ninguno</option>
|
||||||
bind:value={title}
|
{#each albums as album (album.id)}
|
||||||
maxlength={LIMITS.trackTitle}
|
<option value={album.id}>{album.title}</option>
|
||||||
disabled={titleDisabled}
|
{/each}
|
||||||
placeholder={titleDisabled
|
</select>
|
||||||
? 'Varios archivos · edita cada título en la cola'
|
{#snippet hint()}
|
||||||
: 'Nombre de la canción'}
|
<p>
|
||||||
/>
|
¿Otro álbum? <a class="text-fg underline" href="/library/albums/new">Crea uno nuevo</a>
|
||||||
|
y vuelve.
|
||||||
|
</p>
|
||||||
|
{/snippet}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div class="grid gap-7 md:grid-cols-2">
|
|
||||||
<Field label="Visibilidad">
|
|
||||||
<SegmentedControl options={VISIBILITY} bind:value={visibility} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Artistas colaboradores" for="upload-coauthor">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
id="upload-coauthor"
|
|
||||||
bind:value={artist}
|
|
||||||
maxlength={LIMITS.trackTitle}
|
|
||||||
placeholder="Nombre del artista"
|
|
||||||
onkeydown={onArtistKey}
|
|
||||||
/>
|
|
||||||
<IconButton label="Añadir artista" surface onclick={addArtist}>
|
|
||||||
<Plus class="size-icon-sm" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
{#if coauthors.length > 0}
|
|
||||||
<ul class="mt-3 flex flex-wrap gap-2">
|
|
||||||
{#each coauthors as name (name)}
|
|
||||||
<li
|
|
||||||
class="flex items-center gap-1 rounded-full bg-btn py-1 pr-1 pl-3.5 text-xs text-fg"
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
<IconButton
|
|
||||||
label="Quitar a {name}"
|
|
||||||
size="xs"
|
|
||||||
plain
|
|
||||||
onclick={() => (coauthors = coauthors.filter((current) => current !== name))}
|
|
||||||
>
|
|
||||||
<X class="size-icon-xs" />
|
|
||||||
</IconButton>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
{#snippet hint()}
|
|
||||||
<p>Aparecerán como coautores de cada canción.</p>
|
|
||||||
{/snippet}
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function appNavSections(
|
|||||||
label: 'Gestionar música',
|
label: 'Gestionar música',
|
||||||
icon: Music,
|
icon: Music,
|
||||||
inMobileTabs: false,
|
inMobileTabs: false,
|
||||||
activeFor: ['/albums', '/upload']
|
activeFor: ['/albums']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { LIMITS } from '$lib/validation';
|
|
||||||
import { parseTrackUploadForm } from './trackUploadForm';
|
|
||||||
|
|
||||||
function form(fields: Record<string, string | string[] | File>): FormData {
|
|
||||||
const data = new FormData();
|
|
||||||
for (const [key, value] of Object.entries(fields)) {
|
|
||||||
if (Array.isArray(value)) value.forEach((item) => data.append(key, item));
|
|
||||||
else data.set(key, value);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
const audio = new File(['a'], 'a.mp3');
|
|
||||||
const cover = new File(['c'], 'c.png');
|
|
||||||
|
|
||||||
describe('parseTrackUploadForm', () => {
|
|
||||||
it('parses a valid form and deduplicates tags', () => {
|
|
||||||
const parsed = parseTrackUploadForm(
|
|
||||||
form({ title: ' Song ', audio, cover, tags: ['rock', 'rock', 'pop'], isExplicit: 'on' })
|
|
||||||
);
|
|
||||||
expect(parsed).toMatchObject({
|
|
||||||
body: { title: 'Song', tags: ['rock', 'pop'], isExplicit: true }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('validates each field', () => {
|
|
||||||
const ok = { title: 'Song', audio, cover, tags: ['rock'] };
|
|
||||||
expect(parseTrackUploadForm(form({ ...ok, title: '' }))).toHaveProperty('failMessage');
|
|
||||||
expect(
|
|
||||||
parseTrackUploadForm(form({ ...ok, title: 'x'.repeat(LIMITS.trackTitle + 1) }))
|
|
||||||
).toHaveProperty('failMessage');
|
|
||||||
expect(parseTrackUploadForm(form({ ...ok, audio: '' }))).toHaveProperty('failMessage');
|
|
||||||
expect(parseTrackUploadForm(form({ ...ok, cover: '' }))).toHaveProperty('failMessage');
|
|
||||||
expect(parseTrackUploadForm(form({ ...ok, tags: [] }))).toHaveProperty('failMessage');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { formFile, formString } from '$lib/server/forms/fields';
|
|
||||||
import { LIMITS } from '$lib/validation';
|
|
||||||
|
|
||||||
interface TrackUploadBody {
|
|
||||||
title: string;
|
|
||||||
audio: File;
|
|
||||||
cover: File;
|
|
||||||
tags: string[];
|
|
||||||
isExplicit: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseTrackUploadForm(
|
|
||||||
form: FormData
|
|
||||||
): { body: TrackUploadBody } | { failMessage: string } {
|
|
||||||
const title = formString(form, 'title').trim();
|
|
||||||
if (title === '' || title.length > LIMITS.trackTitle) {
|
|
||||||
return { failMessage: `El título es obligatorio (máx. ${LIMITS.trackTitle} caracteres).` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const audio = formFile(form, 'audio');
|
|
||||||
if (!audio) return { failMessage: 'Selecciona un archivo de audio.' };
|
|
||||||
|
|
||||||
const cover = formFile(form, 'cover');
|
|
||||||
if (!cover) return { failMessage: 'Selecciona una portada.' };
|
|
||||||
|
|
||||||
const tags = [...new Set(form.getAll('tags').map(String))];
|
|
||||||
if (tags.length === 0) return { failMessage: 'Elige al menos un género.' };
|
|
||||||
|
|
||||||
return { body: { title, audio, cover, tags, isExplicit: form.get('isExplicit') === 'on' } };
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ const LABELS: [RegExp, string][] = [
|
|||||||
[/^\/library\/albums\/?$/, 'Volver a tus álbumes'],
|
[/^\/library\/albums\/?$/, 'Volver a tus álbumes'],
|
||||||
[/^\/library\/uploads\/?$/, 'Volver a subir música'],
|
[/^\/library\/uploads\/?$/, 'Volver a subir música'],
|
||||||
[/^\/library\/?$/, 'Volver a tus canciones'],
|
[/^\/library\/?$/, 'Volver a tus canciones'],
|
||||||
[/^\/upload\/?$/, 'Volver a subir música'],
|
|
||||||
[/^\/mixes\/[^/]+/, 'Volver a la mezcla']
|
[/^\/mixes\/[^/]+/, 'Volver a la mezcla']
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export const COL = {
|
|||||||
status: '7rem',
|
status: '7rem',
|
||||||
statusQueue: '8.75rem',
|
statusQueue: '8.75rem',
|
||||||
statusMobile: '6rem',
|
statusMobile: '6rem',
|
||||||
access: '6rem',
|
actions: '4.75rem',
|
||||||
|
actionsMobile: '4rem',
|
||||||
size: '5.5rem',
|
size: '5.5rem',
|
||||||
durationQueue: '4.5rem'
|
durationQueue: '4.5rem'
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
import { trackNavigation } from '$lib/state/history.svelte';
|
import { trackNavigation } from '$lib/state/history.svelte';
|
||||||
import { restoreScroll } from '$lib/state/scroll';
|
import { restoreScroll } from '$lib/state/scroll';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { beforeNavigate } from '$app/navigation';
|
||||||
|
import { uploadQueue } from '$lib/state/uploadQueue.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
@@ -34,6 +36,9 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
trackNavigation();
|
trackNavigation();
|
||||||
|
beforeNavigate((navigation) => {
|
||||||
|
if (navigation.willUnload && uploadQueue.active) navigation.cancel();
|
||||||
|
});
|
||||||
restoreScroll(() => scroller);
|
restoreScroll(() => scroller);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { apiFor } from '$lib/server/api';
|
import { apiFor } from '$lib/server/api';
|
||||||
|
import { loadUserAlbums } from '$lib/server/albums';
|
||||||
import { requireUser } from '$lib/server/guards';
|
import { requireUser } from '$lib/server/guards';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||||
requireUser(locals, url);
|
const user = requireUser(locals, url);
|
||||||
const api = apiFor({ fetch, locals });
|
const api = apiFor({ fetch, locals });
|
||||||
const genres = await api.GET('/genres/available');
|
const [genres, albums] = await Promise.all([
|
||||||
return { genres: genres.data ?? [] };
|
api.GET('/genres/available'),
|
||||||
|
loadUserAlbums(api, user.sub)
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
genres: genres.data ?? [],
|
||||||
|
albums: albums.items.map(({ id, title }) => ({ id, title }))
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,4 +4,4 @@
|
|||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LibraryUploadsSection genres={data.genres} />
|
<LibraryUploadsSection genres={data.genres} albums={data.albums} />
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import type { Actions, PageServerLoad } from './$types';
|
|
||||||
import { fail } from '@sveltejs/kit';
|
|
||||||
import { apiFor, requireData } from '$lib/server/api';
|
|
||||||
import { requireUser } from '$lib/server/guards';
|
|
||||||
import { authedAction } from '$lib/server/actions/authedAction';
|
|
||||||
import { parseTrackUploadForm } from '$lib/server/forms/trackUploadForm';
|
|
||||||
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
|
|
||||||
import { findConflict, genreInfo } from '$lib/data/genres';
|
|
||||||
import type { components } from '$lib/api/schema';
|
|
||||||
|
|
||||||
type Genre = components['schemas']['Genre'];
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|
||||||
requireUser(locals, url);
|
|
||||||
const api = apiFor({ fetch, locals });
|
|
||||||
const { data } = await api.GET('/genres/available');
|
|
||||||
return { genres: data ?? [] };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actions: Actions = {
|
|
||||||
default: authedAction(async ({ api, form }) => {
|
|
||||||
const parsed = parseTrackUploadForm(form);
|
|
||||||
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
|
||||||
const { title, audio, cover, tags: rawTags, isExplicit } = parsed.body;
|
|
||||||
|
|
||||||
const { data: available } = await api.GET('/genres/available');
|
|
||||||
const options = available ?? [];
|
|
||||||
const known = new Set<string>(options.map((option) => option.genre));
|
|
||||||
if (!rawTags.every((tag) => known.has(tag))) {
|
|
||||||
return fail(400, { message: 'Alguno de los géneros no es válido.' });
|
|
||||||
}
|
|
||||||
const tags = rawTags as Genre[];
|
|
||||||
const conflict = findConflict(tags, options);
|
|
||||||
if (conflict) {
|
|
||||||
return fail(400, {
|
|
||||||
message: `Los géneros ${genreInfo(conflict[0]).label} y ${genreInfo(conflict[1]).label} no se pueden combinar.`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioContentType = contentTypeOf(audio, AUDIO_TYPES);
|
|
||||||
const pictureContentType = contentTypeOf(cover, IMAGE_TYPES);
|
|
||||||
|
|
||||||
const urlsResult = await api.POST('/tracks/upload-urls', {
|
|
||||||
body: {
|
|
||||||
pictureFileType: extOf(cover.name) || 'jpg',
|
|
||||||
pictureContentType,
|
|
||||||
audioFileType: extOf(audio.name) || 'mp3',
|
|
||||||
audioContentType,
|
|
||||||
expectedPictureSizeBytes: cover.size,
|
|
||||||
expectedAudioSizeBytes: audio.size
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: urls, failure: urlsFailure } = requireData(
|
|
||||||
urlsResult,
|
|
||||||
'No se pudieron reservar las URLs de subida.'
|
|
||||||
);
|
|
||||||
if (urlsFailure) return urlsFailure;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [audioBuffer, coverBuffer] = await Promise.all([
|
|
||||||
audio.arrayBuffer(),
|
|
||||||
cover.arrayBuffer()
|
|
||||||
]);
|
|
||||||
await Promise.all([
|
|
||||||
putPresigned(urls.audioUploadUrl, Buffer.from(audioBuffer), urls.audioContentType),
|
|
||||||
urls.pictureUploadUrl && urls.pictureContentType
|
|
||||||
? putPresigned(urls.pictureUploadUrl, Buffer.from(coverBuffer), urls.pictureContentType)
|
|
||||||
: Promise.resolve()
|
|
||||||
]);
|
|
||||||
} catch (err) {
|
|
||||||
return fail(502, {
|
|
||||||
message: 'Falló la subida del archivo al almacenamiento.',
|
|
||||||
detail: err instanceof Error ? err.message : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackResult = await api.POST('/tracks', {
|
|
||||||
body: {
|
|
||||||
title,
|
|
||||||
pictureIntentId: urls.pictureIntentId,
|
|
||||||
audioIntentId: urls.audioIntentId,
|
|
||||||
tags,
|
|
||||||
isExplicit
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: track, failure: trackFailure } = requireData(
|
|
||||||
trackResult,
|
|
||||||
'No se pudo crear la pista tras la subida.'
|
|
||||||
);
|
|
||||||
if (trackFailure) return trackFailure;
|
|
||||||
|
|
||||||
return { success: true, trackId: track.id, title: track.title };
|
|
||||||
}, 'Inicia sesión para subir música.')
|
|
||||||
};
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import Headphones from '@lucide/svelte/icons/headphones';
|
|
||||||
import Music from '@lucide/svelte/icons/music';
|
|
||||||
import ImageIcon from '@lucide/svelte/icons/image';
|
|
||||||
import Check from '@lucide/svelte/icons/check';
|
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
|
||||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
|
||||||
import Surface from '$lib/components/ui/primitives/Surface.svelte';
|
|
||||||
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
|
|
||||||
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
|
||||||
import Field from '$lib/components/ui/primitives/Field.svelte';
|
|
||||||
import Input from '$lib/components/ui/primitives/Input.svelte';
|
|
||||||
import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte';
|
|
||||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
|
||||||
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
|
|
||||||
import { LIMITS } from '$lib/validation';
|
|
||||||
import { formatSize } from '$lib/utils/format';
|
|
||||||
import GenrePicker from '$lib/components/music/GenrePicker.svelte';
|
|
||||||
|
|
||||||
type Status = 'idle' | 'uploading' | 'done' | 'error';
|
|
||||||
type StepState = 'idle' | 'active' | 'done';
|
|
||||||
|
|
||||||
let { data } = $props();
|
|
||||||
|
|
||||||
let title = $state('');
|
|
||||||
let audioName = $state('');
|
|
||||||
let audioSize = $state(0);
|
|
||||||
let hasCover = $state(false);
|
|
||||||
let coverResetToken = $state(0);
|
|
||||||
let dragging = $state(false);
|
|
||||||
let status = $state<Status>('idle');
|
|
||||||
let acceptedTerms = $state(false);
|
|
||||||
let isExplicit = $state(false);
|
|
||||||
let errorMsg = $state('');
|
|
||||||
let errorDetail = $state('');
|
|
||||||
let tags = $state<string[]>([]);
|
|
||||||
let publishedTitle = $state('');
|
|
||||||
let audioInput = $state<HTMLInputElement>();
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{ key: 'upload', label: 'Subir', hint: 'Portada y audio.' },
|
|
||||||
{ key: 'done', label: 'Listo', hint: 'Se procesa y aparece en tu biblioteca.' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const busy = $derived(status === 'uploading');
|
|
||||||
|
|
||||||
const canPublish = $derived(
|
|
||||||
title.trim() !== '' && audioName !== '' && hasCover && tags.length > 0 && acceptedTerms && !busy
|
|
||||||
);
|
|
||||||
|
|
||||||
const stepStates = $derived.by<StepState[]>(() => {
|
|
||||||
if (status === 'done') return ['done', 'done'];
|
|
||||||
if (status === 'uploading') return ['active', 'idle'];
|
|
||||||
return ['idle', 'idle'];
|
|
||||||
});
|
|
||||||
|
|
||||||
function stepClass(s: StepState) {
|
|
||||||
return s === 'idle' ? '' : 'bg-accent-tint';
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickAudio(file: File) {
|
|
||||||
audioName = file.name;
|
|
||||||
audioSize = file.size;
|
|
||||||
if (title.trim() === '') title = file.name.replace(/\.[^.]+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function onAudioInput(event: Event) {
|
|
||||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
|
||||||
if (file) pickAudio(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDrop(event: DragEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
dragging = false;
|
|
||||||
const file = event.dataTransfer?.files?.[0];
|
|
||||||
if (!file || !audioInput) return;
|
|
||||||
const dt = new DataTransfer();
|
|
||||||
dt.items.add(file);
|
|
||||||
audioInput.files = dt.files;
|
|
||||||
pickAudio(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
title = '';
|
|
||||||
audioName = '';
|
|
||||||
audioSize = 0;
|
|
||||||
hasCover = false;
|
|
||||||
coverResetToken += 1;
|
|
||||||
status = 'idle';
|
|
||||||
acceptedTerms = false;
|
|
||||||
isExplicit = false;
|
|
||||||
errorMsg = '';
|
|
||||||
errorDetail = '';
|
|
||||||
tags = [];
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Subir música</title>
|
|
||||||
<meta name="description" content="Sube tus canciones a Musify: portada, audio y título." />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<Page>
|
|
||||||
<PageHeader
|
|
||||||
title="Subir música"
|
|
||||||
description="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ol class="grid gap-3 sm:grid-cols-2 sm:gap-4">
|
|
||||||
{#each steps as step, i (step.key)}
|
|
||||||
<li>
|
|
||||||
<Surface padding="sm" class={stepClass(stepStates[i])}>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span
|
|
||||||
class="grid size-7 shrink-0 place-items-center rounded-full text-sm {stepStates[i] ===
|
|
||||||
'idle'
|
|
||||||
? 'bg-surface-2 text-fg-2'
|
|
||||||
: 'bg-accent-btn text-accent-soft'}"
|
|
||||||
>
|
|
||||||
{#if stepStates[i] === 'done'}
|
|
||||||
<Check class="size-icon-xs" />
|
|
||||||
{:else}
|
|
||||||
{i + 1}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm font-medium text-fg">{step.label}</span>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2.5 text-xs text-fg-2">{step.hint}</p>
|
|
||||||
</Surface>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
{#if status === 'done'}
|
|
||||||
<Surface padding="lg" class="mt-10 bg-accent-tint text-center">
|
|
||||||
<span class="mx-auto grid size-14 place-items-center rounded-full bg-accent-soft text-ink">
|
|
||||||
<Check class="size-icon-2xl" />
|
|
||||||
</span>
|
|
||||||
<h2 class="mt-6 font-display text-2xl font-semibold tracking-display text-fg">¡Subida!</h2>
|
|
||||||
<p class="mt-3 text-sm text-fg-2">
|
|
||||||
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
|
|
||||||
unos momentos.
|
|
||||||
</p>
|
|
||||||
<div class="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row sm:gap-4">
|
|
||||||
<Button href="/library" size="lg" class="w-full sm:w-auto">Ir a la biblioteca</Button>
|
|
||||||
<Button variant="secondary" size="lg" onclick={reset} class="w-full sm:w-auto">
|
|
||||||
Subir otra
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Surface>
|
|
||||||
{:else}
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
enctype="multipart/form-data"
|
|
||||||
use:enhance={() => {
|
|
||||||
status = 'uploading';
|
|
||||||
errorMsg = '';
|
|
||||||
errorDetail = '';
|
|
||||||
return async ({ result }) => {
|
|
||||||
if (result.type === 'success') {
|
|
||||||
publishedTitle = title;
|
|
||||||
status = 'done';
|
|
||||||
} else if (result.type === 'failure') {
|
|
||||||
status = 'error';
|
|
||||||
errorMsg = (result.data?.message as string) ?? 'No se pudo subir la canción.';
|
|
||||||
errorDetail = (result.data?.detail as string | undefined) ?? '';
|
|
||||||
} else {
|
|
||||||
status = 'error';
|
|
||||||
errorMsg = 'Error inesperado durante la subida.';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="mt-10 space-y-8"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
ondragover={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
dragging = true;
|
|
||||||
}}
|
|
||||||
ondragleave={() => (dragging = false)}
|
|
||||||
ondrop={onDrop}
|
|
||||||
class="relative flex min-h-48 flex-col items-center justify-center rounded-panel p-6 text-center transition sm:min-h-64 sm:p-10 {dragging
|
|
||||||
? 'bg-surface-2 ring-2 ring-accent/50'
|
|
||||||
: 'bg-surface'}"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
bind:this={audioInput}
|
|
||||||
type="file"
|
|
||||||
name="audio"
|
|
||||||
accept="audio/*"
|
|
||||||
onchange={onAudioInput}
|
|
||||||
class="absolute inset-0 cursor-pointer opacity-0 focus:outline-none"
|
|
||||||
aria-label="Seleccionar archivo de audio"
|
|
||||||
/>
|
|
||||||
{#if audioName === ''}
|
|
||||||
<Headphones class="size-icon-xl text-fg-2 sm:size-icon-3xl" />
|
|
||||||
{:else}
|
|
||||||
<Music class="size-icon-xl text-accent-soft sm:size-icon-3xl" />
|
|
||||||
{/if}
|
|
||||||
{#if audioName === ''}
|
|
||||||
<p class="mt-4 text-sm font-medium text-fg">
|
|
||||||
Arrastra tu audio aquí o haz clic para elegir
|
|
||||||
</p>
|
|
||||||
<p class="mt-1.5 text-xs text-fg-2">MP3, FLAC, WAV…</p>
|
|
||||||
{:else}
|
|
||||||
<p class="mt-4 max-w-full truncate text-sm font-medium text-fg">
|
|
||||||
{audioName}
|
|
||||||
</p>
|
|
||||||
<p class="mt-1.5 text-xs text-fg-2">{formatSize(audioSize)} · Listo para subir</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Surface padding="md">
|
|
||||||
<div class="grid items-start gap-6 md:grid-cols-[auto_1fr] md:gap-10">
|
|
||||||
<Field label="Portada" class="items-center md:items-start">
|
|
||||||
{#key coverResetToken}
|
|
||||||
<ImageDropzone
|
|
||||||
name="cover"
|
|
||||||
icon={ImageIcon}
|
|
||||||
gradient
|
|
||||||
onselect={() => (hasCover = true)}
|
|
||||||
/>
|
|
||||||
{/key}
|
|
||||||
{#snippet hint()}
|
|
||||||
<p>{hasCover ? 'Portada lista' : 'Haz clic para elegir'}</p>
|
|
||||||
{/snippet}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-7">
|
|
||||||
<Field label="Título" for="title">
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
name="title"
|
|
||||||
bind:value={title}
|
|
||||||
maxlength={LIMITS.trackTitle}
|
|
||||||
placeholder="Nombre de la canción"
|
|
||||||
/>
|
|
||||||
{#snippet hint()}
|
|
||||||
<p>Se usa como nombre público de la pista en el catálogo.</p>
|
|
||||||
<span class="shrink-0 tabular-nums">{title.length}/{LIMITS.trackTitle}</span>
|
|
||||||
{/snippet}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<GenrePicker
|
|
||||||
genres={data.genres}
|
|
||||||
bind:selected={tags}
|
|
||||||
name="tags"
|
|
||||||
help="Elige al menos uno. Algunos géneros no se pueden combinar, como Metal con Ambient."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Checkbox bind:checked={isExplicit} name="isExplicit">
|
|
||||||
<span class="inline-flex items-center gap-2">
|
|
||||||
Contenido explícito
|
|
||||||
<ExplicitBadge />
|
|
||||||
</span>
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Surface>
|
|
||||||
|
|
||||||
<Checkbox bind:checked={acceptedTerms}>
|
|
||||||
Acepto los términos y condiciones al subir {title.trim() !== ''
|
|
||||||
? `«${title}»`
|
|
||||||
: 'esta canción'}.
|
|
||||||
</Checkbox>
|
|
||||||
|
|
||||||
{#if status === 'uploading'}
|
|
||||||
<Surface padding="sm">
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
|
||||||
<span class="font-medium text-fg">Subiendo y creando la pista…</span>
|
|
||||||
<span class="text-xs text-fg-2">un momento</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3 h-2 overflow-hidden rounded-full bg-surface-2">
|
|
||||||
<div class="h-full w-full animate-pulse rounded-full bg-accent-soft"></div>
|
|
||||||
</div>
|
|
||||||
</Surface>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if status === 'error'}
|
|
||||||
<Alert tone="danger">
|
|
||||||
{errorMsg}
|
|
||||||
{#if errorDetail}<span class="mt-1 block text-xs opacity-80">{errorDetail}</span>{/if}
|
|
||||||
</Alert>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-end sm:gap-4">
|
|
||||||
<Button href="/explore" variant="secondary" size="lg" class="w-full sm:w-auto">
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" size="lg" disabled={!canPublish} class="w-full sm:w-auto">
|
|
||||||
Publicar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
</Page>
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = () => redirect(308, '/library/uploads');
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { GET } from './+server';
|
||||||
|
|
||||||
|
describe('GET /upload', () => {
|
||||||
|
it('redirects permanently to the uploads section', () => {
|
||||||
|
try {
|
||||||
|
GET({} as Parameters<typeof GET>[0]);
|
||||||
|
expect.unreachable();
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toMatchObject({ status: 308, location: '/library/uploads' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user