web-player: share form limits between client and server and extract form parsers
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
import Field from '../primitives/Field.svelte';
|
import Field from '../primitives/Field.svelte';
|
||||||
import Input from '../primitives/Input.svelte';
|
import Input from '../primitives/Input.svelte';
|
||||||
import Textarea from '../primitives/Textarea.svelte';
|
import Textarea from '../primitives/Textarea.svelte';
|
||||||
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
|
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -58,10 +58,13 @@
|
|||||||
>
|
>
|
||||||
{#snippet fields()}
|
{#snippet fields()}
|
||||||
<Field label="Título" for="album-title">
|
<Field label="Título" for="album-title">
|
||||||
|
{#snippet hint()}
|
||||||
|
<span class="ml-auto tabular-nums">{title.length}/{LIMITS.albumTitle}</span>
|
||||||
|
{/snippet}
|
||||||
<Input
|
<Input
|
||||||
id="album-title"
|
id="album-title"
|
||||||
name="title"
|
name="title"
|
||||||
maxlength={200}
|
maxlength={LIMITS.albumTitle}
|
||||||
required
|
required
|
||||||
bind:value={title}
|
bind:value={title}
|
||||||
placeholder={titlePlaceholder}
|
placeholder={titlePlaceholder}
|
||||||
@@ -85,7 +88,7 @@
|
|||||||
<Textarea
|
<Textarea
|
||||||
id="album-description"
|
id="album-description"
|
||||||
name="description"
|
name="description"
|
||||||
maxlength={256}
|
maxlength={LIMITS.albumDescription}
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
placeholder="¿De qué va este álbum?"
|
placeholder="¿De qué va este álbum?"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
import Textarea from '../primitives/Textarea.svelte';
|
import Textarea from '../primitives/Textarea.svelte';
|
||||||
import SegmentedControl from '../primitives/SegmentedControl.svelte';
|
import SegmentedControl from '../primitives/SegmentedControl.svelte';
|
||||||
|
|
||||||
const NAME_MAX_LENGTH = 60;
|
import { LIMITS } from '$lib/validation';
|
||||||
const DESCRIPTION_MAX_LENGTH = 300;
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -61,12 +60,12 @@
|
|||||||
<input type="hidden" name="visibility" value={visibility} />
|
<input type="hidden" name="visibility" value={visibility} />
|
||||||
<Field label="Nombre" for="playlist-name">
|
<Field label="Nombre" for="playlist-name">
|
||||||
{#snippet hint()}
|
{#snippet hint()}
|
||||||
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
|
<span class="ml-auto tabular-nums">{name.length}/{LIMITS.playlistName}</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<Input
|
<Input
|
||||||
id="playlist-name"
|
id="playlist-name"
|
||||||
name="name"
|
name="name"
|
||||||
maxlength={NAME_MAX_LENGTH}
|
maxlength={LIMITS.playlistName}
|
||||||
required
|
required
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
placeholder={namePlaceholder}
|
placeholder={namePlaceholder}
|
||||||
@@ -77,7 +76,7 @@
|
|||||||
<Textarea
|
<Textarea
|
||||||
id="playlist-description"
|
id="playlist-description"
|
||||||
name="description"
|
name="description"
|
||||||
maxlength={DESCRIPTION_MAX_LENGTH}
|
maxlength={LIMITS.playlistDescription}
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
|
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export const INFINITE_SCROLL_ROOT_MARGIN = '300px';
|
|||||||
export const ALBUMS_PAGE_SIZE = 50;
|
export const ALBUMS_PAGE_SIZE = 50;
|
||||||
export const ALBUM_TRACKS_PAGE_SIZE = 200;
|
export const ALBUM_TRACKS_PAGE_SIZE = 200;
|
||||||
export const PLAYLIST_TRACKS_PAGE_SIZE = 200;
|
export const PLAYLIST_TRACKS_PAGE_SIZE = 200;
|
||||||
export const ALBUM_EARLIEST_YEAR = 1877;
|
|
||||||
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
||||||
export const LIBRARY_PAGE_SIZE = 50;
|
export const LIBRARY_PAGE_SIZE = 50;
|
||||||
export const PLAYLISTS_PAGE_SIZE = 50;
|
export const PLAYLISTS_PAGE_SIZE = 50;
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
|
import { ALBUM_EARLIEST_YEAR } from '$lib/validation';
|
||||||
import { parseAlbumForm } from './albumForm';
|
import { parseAlbumForm } from './albumForm';
|
||||||
|
|
||||||
function form(fields: Record<string, string>): FormData {
|
function form(fields: Record<string, string>): FormData {
|
||||||
+7
-5
@@ -1,5 +1,5 @@
|
|||||||
import { formString } from '$lib/server/api';
|
import { formString } from '$lib/server/api';
|
||||||
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
|
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
|
||||||
|
|
||||||
interface AlbumFormBody {
|
interface AlbumFormBody {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -9,13 +9,15 @@ interface AlbumFormBody {
|
|||||||
|
|
||||||
export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } {
|
export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } {
|
||||||
const title = formString(form, 'title').trim();
|
const title = formString(form, 'title').trim();
|
||||||
if (title === '' || title.length > 200) {
|
if (title === '' || title.length > LIMITS.albumTitle) {
|
||||||
return { failMessage: 'El título es obligatorio (máx. 200 caracteres).' };
|
return { failMessage: `El título es obligatorio (máx. ${LIMITS.albumTitle} caracteres).` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = formString(form, 'description').trim();
|
const description = formString(form, 'description').trim();
|
||||||
if (description.length > 256) {
|
if (description.length > LIMITS.albumDescription) {
|
||||||
return { failMessage: 'La descripción no puede pasar de 256 caracteres.' };
|
return {
|
||||||
|
failMessage: `La descripción no puede pasar de ${LIMITS.albumDescription} caracteres.`
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawYear = formString(form, 'releaseYear').trim();
|
const rawYear = formString(form, 'releaseYear').trim();
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { LIMITS } from '$lib/validation';
|
||||||
|
import { parsePlaylistForm } from './playlistForm';
|
||||||
|
|
||||||
|
function form(fields: Record<string, string>): FormData {
|
||||||
|
const data = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(fields)) data.set(key, value);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parsePlaylistForm', () => {
|
||||||
|
it('parses a valid form', () => {
|
||||||
|
expect(
|
||||||
|
parsePlaylistForm(form({ name: ' Mix ', description: ' d ', visibility: 'public' }))
|
||||||
|
).toEqual({ body: { name: 'Mix', description: 'd', visibility: 'Public', cover: null } });
|
||||||
|
});
|
||||||
|
it('defaults to private', () => {
|
||||||
|
const parsed = parsePlaylistForm(form({ name: 'Mix' }));
|
||||||
|
expect(parsed).toMatchObject({ body: { visibility: 'Private', description: '' } });
|
||||||
|
});
|
||||||
|
it('rejects empty or too long names keeping the typed values', () => {
|
||||||
|
expect(parsePlaylistForm(form({ name: ' ', description: 'd' }))).toMatchObject({
|
||||||
|
failMessage: expect.any(String),
|
||||||
|
description: 'd'
|
||||||
|
});
|
||||||
|
expect(parsePlaylistForm(form({ name: 'x'.repeat(LIMITS.playlistName + 1) }))).toHaveProperty(
|
||||||
|
'failMessage'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('rejects too long descriptions', () => {
|
||||||
|
const description = 'x'.repeat(LIMITS.playlistDescription + 1);
|
||||||
|
expect(parsePlaylistForm(form({ name: 'Mix', description }))).toHaveProperty('failMessage');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { formFile, formString } from '$lib/server/api';
|
||||||
|
import { LIMITS } from '$lib/validation';
|
||||||
|
|
||||||
|
interface PlaylistFormBody {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
visibility: 'Public' | 'Private';
|
||||||
|
cover: File | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlaylistFormFailure = { failMessage: string; name: string; description: string };
|
||||||
|
|
||||||
|
export function parsePlaylistForm(
|
||||||
|
form: FormData
|
||||||
|
): { body: PlaylistFormBody } | PlaylistFormFailure {
|
||||||
|
const name = formString(form, 'name').trim();
|
||||||
|
const description = formString(form, 'description').trim();
|
||||||
|
|
||||||
|
if (name === '' || name.length > LIMITS.playlistName) {
|
||||||
|
return {
|
||||||
|
failMessage: `El nombre es obligatorio (máx. ${LIMITS.playlistName} caracteres).`,
|
||||||
|
name,
|
||||||
|
description
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (description.length > LIMITS.playlistDescription) {
|
||||||
|
return {
|
||||||
|
failMessage: `La descripción no puede pasar de ${LIMITS.playlistDescription} caracteres.`,
|
||||||
|
name,
|
||||||
|
description
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
body: {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
visibility: formString(form, 'visibility') === 'public' ? 'Public' : 'Private',
|
||||||
|
cover: formFile(form, 'cover')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { formFile, formString } from '$lib/server/api';
|
||||||
|
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' } };
|
||||||
|
}
|
||||||
@@ -78,6 +78,8 @@ export async function putPresigned(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { ApiClient } from '$lib/server/api';
|
||||||
|
|
||||||
export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string };
|
export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string };
|
||||||
|
|
||||||
export async function uploadPresignedImage(
|
export async function uploadPresignedImage(
|
||||||
@@ -109,3 +111,18 @@ export async function uploadPresignedImage(
|
|||||||
|
|
||||||
return { intentId: reserved.intentId };
|
return { intentId: reserved.intentId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadOptionalCover(
|
||||||
|
api: ApiClient,
|
||||||
|
endpoint: '/albums/upload-picture' | '/playlists/upload-picture',
|
||||||
|
file: File | null
|
||||||
|
): Promise<{ intentId: string | null } | { failMessage: string; detail?: string }> {
|
||||||
|
if (!file) return { intentId: null };
|
||||||
|
return uploadPresignedImage(
|
||||||
|
(args) =>
|
||||||
|
endpoint === '/albums/upload-picture'
|
||||||
|
? api.POST('/albums/upload-picture', { body: args })
|
||||||
|
: api.POST('/playlists/upload-picture', { body: args }),
|
||||||
|
file
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ export function albumMeta(releaseYear?: number | null, trackCount?: number): str
|
|||||||
.join(' · ');
|
.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '';
|
||||||
|
const mb = bytes / (1024 * 1024);
|
||||||
|
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
export function playlistMeta(trackCount: number): string {
|
export function playlistMeta(trackCount: number): string {
|
||||||
return plural(trackCount, 'canción', 'canciones');
|
return plural(trackCount, 'canción', 'canciones');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export const ALBUM_EARLIEST_YEAR = 1877;
|
||||||
|
|
||||||
|
export const LIMITS = {
|
||||||
|
playlistName: 50,
|
||||||
|
playlistDescription: 256,
|
||||||
|
albumTitle: 200,
|
||||||
|
albumDescription: 256,
|
||||||
|
trackTitle: 200
|
||||||
|
} as const;
|
||||||
@@ -3,7 +3,7 @@ import { fail, redirect } from '@sveltejs/kit';
|
|||||||
import { apiFor, authedAction, formFile, requireUser, unwrapOrError } from '$lib/server/api';
|
import { apiFor, authedAction, formFile, requireUser, unwrapOrError } from '$lib/server/api';
|
||||||
import { toAlbum, toPage } from '$lib/server/mappers';
|
import { toAlbum, toPage } from '$lib/server/mappers';
|
||||||
import { ALBUMS_PAGE_SIZE } from '$lib/config';
|
import { ALBUMS_PAGE_SIZE } from '$lib/config';
|
||||||
import { parseAlbumForm } from '$lib/server/albumForm';
|
import { parseAlbumForm } from '$lib/server/forms/albumForm';
|
||||||
import { uploadPresignedImage } from '$lib/server/upload';
|
import { uploadPresignedImage } from '$lib/server/upload';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
} from '$lib/server/api';
|
} from '$lib/server/api';
|
||||||
import { toAlbum, toTrack } from '$lib/server/mappers';
|
import { toAlbum, toTrack } from '$lib/server/mappers';
|
||||||
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
|
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
|
||||||
import { parseAlbumForm } from '$lib/server/albumForm';
|
import { parseAlbumForm } from '$lib/server/forms/albumForm';
|
||||||
import { uploadPresignedImage } from '$lib/server/upload';
|
import { uploadOptionalCover } from '$lib/server/upload';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||||
const { allowAnonymousListening } = await parent();
|
const { allowAnonymousListening } = await parent();
|
||||||
@@ -75,17 +75,9 @@ export const actions: Actions = {
|
|||||||
const parsed = parseAlbumForm(form);
|
const parsed = parseAlbumForm(form);
|
||||||
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
||||||
|
|
||||||
let newPictureIntentId: string | null = null;
|
const cover = await uploadOptionalCover(api, '/albums/upload-picture', formFile(form, 'cover'));
|
||||||
const cover = formFile(form, 'cover');
|
if ('failMessage' in cover) {
|
||||||
if (cover) {
|
return fail(502, { message: cover.failMessage, detail: cover.detail });
|
||||||
const uploaded = await uploadPresignedImage(
|
|
||||||
(args) => api.POST('/albums/upload-picture', { body: args }),
|
|
||||||
cover
|
|
||||||
);
|
|
||||||
if ('failMessage' in uploaded) {
|
|
||||||
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
|
|
||||||
}
|
|
||||||
newPictureIntentId = uploaded.intentId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await api.PUT('/albums/{albumId}', {
|
const result = await api.PUT('/albums/{albumId}', {
|
||||||
@@ -94,7 +86,7 @@ export const actions: Actions = {
|
|||||||
newTitle: parsed.body.title,
|
newTitle: parsed.body.title,
|
||||||
newDescription: parsed.body.description,
|
newDescription: parsed.body.description,
|
||||||
newReleaseYear: parsed.body.releaseYear,
|
newReleaseYear: parsed.body.releaseYear,
|
||||||
newPictureIntentId
|
newPictureIntentId: cover.intentId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return failOnError(result, 'No se pudo actualizar el álbum.') ?? { edited: true };
|
return failOnError(result, 'No se pudo actualizar el álbum.') ?? { edited: true };
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import {
|
import { apiFor, authedAction, requireUser, unwrapOrError } from '$lib/server/api';
|
||||||
apiFor,
|
|
||||||
authedAction,
|
|
||||||
formFile,
|
|
||||||
formString,
|
|
||||||
requireUser,
|
|
||||||
unwrapOrError
|
|
||||||
} from '$lib/server/api';
|
|
||||||
import { PLAYLISTS_PAGE_SIZE, PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } from '$lib/config';
|
import { PLAYLISTS_PAGE_SIZE, PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||||
import { toPlaylistSummary, toTrack } from '$lib/server/mappers';
|
import { toPlaylistSummary, toTrack } from '$lib/server/mappers';
|
||||||
import { uploadPresignedImage } from '$lib/server/upload';
|
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
|
||||||
|
import { uploadOptionalCover } from '$lib/server/upload';
|
||||||
const MAX_NAME = 100;
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||||
const user = requireUser(locals, url);
|
const user = requireUser(locals, url);
|
||||||
@@ -51,33 +43,25 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
create: authedAction(async ({ api, form }) => {
|
create: authedAction(async ({ api, form }) => {
|
||||||
const name = formString(form, 'name').trim();
|
const parsed = parsePlaylistForm(form);
|
||||||
const description = formString(form, 'description').trim();
|
if ('failMessage' in parsed) {
|
||||||
const visibility = formString(form, 'visibility') === 'public' ? 'Public' : 'Private';
|
const { failMessage: message, name, description } = parsed;
|
||||||
|
return fail(400, { message, name, description });
|
||||||
|
}
|
||||||
|
const { name, description, visibility, cover } = parsed.body;
|
||||||
|
|
||||||
if (name === '' || name.length > MAX_NAME) {
|
const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover);
|
||||||
return fail(400, {
|
if ('failMessage' in uploaded) {
|
||||||
message: 'El nombre es obligatorio (máx. 100 caracteres).',
|
return fail(502, {
|
||||||
|
message: uploaded.failMessage,
|
||||||
name,
|
name,
|
||||||
description
|
description,
|
||||||
|
detail: uploaded.detail
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let pictureIntentId: string | null = null;
|
|
||||||
const cover = formFile(form, 'cover');
|
|
||||||
if (cover) {
|
|
||||||
const result = await uploadPresignedImage(
|
|
||||||
(args) => api.POST('/playlists/upload-picture', { body: args }),
|
|
||||||
cover
|
|
||||||
);
|
|
||||||
if ('failMessage' in result) {
|
|
||||||
return fail(502, { message: result.failMessage, name, description, detail: result.detail });
|
|
||||||
}
|
|
||||||
pictureIntentId = result.intentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await api.POST('/playlists', {
|
const { data, error } = await api.POST('/playlists', {
|
||||||
body: { name, description, pictureIntentId, visibility }
|
body: { name, description, pictureIntentId: uploaded.intentId, visibility }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error || !data)
|
if (error || !data)
|
||||||
|
|||||||
@@ -4,16 +4,14 @@ import {
|
|||||||
apiFor,
|
apiFor,
|
||||||
authedAction,
|
authedAction,
|
||||||
failOnError,
|
failOnError,
|
||||||
formFile,
|
|
||||||
formString,
|
formString,
|
||||||
optionalUser,
|
optionalUser,
|
||||||
unwrapOrError
|
unwrapOrError
|
||||||
} from '$lib/server/api';
|
} from '$lib/server/api';
|
||||||
import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
|
import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||||
import { toDatedTrack, toPlaylist } from '$lib/server/mappers';
|
import { toDatedTrack, toPlaylist } from '$lib/server/mappers';
|
||||||
import { uploadPresignedImage } from '$lib/server/upload';
|
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
|
||||||
|
import { uploadOptionalCover } from '$lib/server/upload';
|
||||||
const MAX_NAME = 100;
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||||
const { allowAnonymousListening } = await parent();
|
const { allowAnonymousListening } = await parent();
|
||||||
@@ -50,29 +48,23 @@ export const actions: Actions = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
edit: authedAction(async ({ api, form, params }) => {
|
edit: authedAction(async ({ api, form, params }) => {
|
||||||
const name = formString(form, 'name').trim();
|
const parsed = parsePlaylistForm(form);
|
||||||
const description = formString(form, 'description').trim();
|
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
||||||
const newVisibility = formString(form, 'visibility') === 'public' ? 'Public' : 'Private';
|
const { name, description, visibility, cover } = parsed.body;
|
||||||
if (name === '' || name.length > MAX_NAME) {
|
|
||||||
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let newPictureIntentId: string | null = null;
|
const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover);
|
||||||
const cover = formFile(form, 'cover');
|
if ('failMessage' in uploaded) {
|
||||||
if (cover) {
|
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
|
||||||
const result = await uploadPresignedImage(
|
|
||||||
(args) => api.POST('/playlists/upload-picture', { body: args }),
|
|
||||||
cover
|
|
||||||
);
|
|
||||||
if ('failMessage' in result) {
|
|
||||||
return fail(502, { message: result.failMessage, detail: result.detail });
|
|
||||||
}
|
|
||||||
newPictureIntentId = result.intentId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await api.PUT('/playlists/{playlistId}', {
|
const result = await api.PUT('/playlists/{playlistId}', {
|
||||||
params: { path: { playlistId: params.id } },
|
params: { path: { playlistId: params.id } },
|
||||||
body: { newName: name, newDescription: description, newPictureIntentId, newVisibility }
|
body: {
|
||||||
|
newName: name,
|
||||||
|
newDescription: description,
|
||||||
|
newPictureIntentId: uploaded.intentId,
|
||||||
|
newVisibility: visibility
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return failOnError(result, 'No se pudo actualizar la playlist.') ?? { edited: true };
|
return failOnError(result, 'No se pudo actualizar la playlist.') ?? { edited: true };
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import {
|
import { apiFor, authedAction, requireData, requireUser } from '$lib/server/api';
|
||||||
apiFor,
|
import { parseTrackUploadForm } from '$lib/server/forms/trackUploadForm';
|
||||||
authedAction,
|
|
||||||
formFile,
|
|
||||||
formString,
|
|
||||||
requireData,
|
|
||||||
requireUser
|
|
||||||
} from '$lib/server/api';
|
|
||||||
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
|
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
|
||||||
import { findConflict, genreInfo } from '$lib/data/genres';
|
import { findConflict, genreInfo } from '$lib/data/genres';
|
||||||
import type { components } from '$lib/api/schema';
|
import type { components } from '$lib/api/schema';
|
||||||
|
|
||||||
type Genre = components['schemas']['Genre'];
|
type Genre = components['schemas']['Genre'];
|
||||||
|
|
||||||
const MAX_TITLE = 100;
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||||
requireUser(locals, url);
|
requireUser(locals, url);
|
||||||
const api = apiFor({ fetch, locals });
|
const api = apiFor({ fetch, locals });
|
||||||
@@ -25,28 +17,17 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: authedAction(async ({ api, form }) => {
|
default: authedAction(async ({ api, form }) => {
|
||||||
const title = formString(form, 'title').trim();
|
const parsed = parseTrackUploadForm(form);
|
||||||
const audio = formFile(form, 'audio');
|
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
||||||
const cover = formFile(form, 'cover');
|
const { title, audio, cover, tags: rawTags, isExplicit } = parsed.body;
|
||||||
const rawTags = form.getAll('tags').map(String);
|
|
||||||
const isExplicit = form.get('isExplicit') === 'on';
|
|
||||||
|
|
||||||
if (title === '' || title.length > MAX_TITLE) {
|
|
||||||
return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' });
|
|
||||||
}
|
|
||||||
if (!audio) return fail(400, { message: 'Selecciona un archivo de audio.' });
|
|
||||||
if (!cover) return fail(400, { message: 'Selecciona una portada.' });
|
|
||||||
|
|
||||||
if (rawTags.length === 0) {
|
|
||||||
return fail(400, { message: 'Elige al menos un género.' });
|
|
||||||
}
|
|
||||||
const { data: available } = await api.GET('/genres/available');
|
const { data: available } = await api.GET('/genres/available');
|
||||||
const options = available ?? [];
|
const options = available ?? [];
|
||||||
const known = new Set<string>(options.map((option) => option.genre));
|
const known = new Set<string>(options.map((option) => option.genre));
|
||||||
if (!rawTags.every((tag) => known.has(tag))) {
|
if (!rawTags.every((tag) => known.has(tag))) {
|
||||||
return fail(400, { message: 'Alguno de los géneros no es válido.' });
|
return fail(400, { message: 'Alguno de los géneros no es válido.' });
|
||||||
}
|
}
|
||||||
const tags = [...new Set(rawTags)] as Genre[];
|
const tags = rawTags as Genre[];
|
||||||
const conflict = findConflict(tags, options);
|
const conflict = findConflict(tags, options);
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
|
|||||||
@@ -15,13 +15,13 @@
|
|||||||
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 Chip from '$lib/components/ui/primitives/Chip.svelte';
|
||||||
|
import { LIMITS } from '$lib/validation';
|
||||||
|
import { formatSize } from '$lib/utils/format';
|
||||||
import { genreOptions } from '$lib/data/genres';
|
import { genreOptions } from '$lib/data/genres';
|
||||||
|
|
||||||
type Status = 'idle' | 'uploading' | 'done' | 'error';
|
type Status = 'idle' | 'uploading' | 'done' | 'error';
|
||||||
type StepState = 'idle' | 'active' | 'done';
|
type StepState = 'idle' | 'active' | 'done';
|
||||||
|
|
||||||
const MAX_TITLE = 100;
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let title = $state('');
|
let title = $state('');
|
||||||
@@ -70,12 +70,6 @@
|
|||||||
return s === 'idle' ? '' : 'bg-accent-tint';
|
return s === 'idle' ? '' : 'bg-accent-tint';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes: number) {
|
|
||||||
if (bytes === 0) return '';
|
|
||||||
const mb = bytes / (1024 * 1024);
|
|
||||||
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickAudio(file: File) {
|
function pickAudio(file: File) {
|
||||||
audioName = file.name;
|
audioName = file.name;
|
||||||
audioSize = file.size;
|
audioSize = file.size;
|
||||||
@@ -256,12 +250,12 @@
|
|||||||
id="title"
|
id="title"
|
||||||
name="title"
|
name="title"
|
||||||
bind:value={title}
|
bind:value={title}
|
||||||
maxlength={MAX_TITLE}
|
maxlength={LIMITS.trackTitle}
|
||||||
placeholder="Nombre de la canción"
|
placeholder="Nombre de la canción"
|
||||||
/>
|
/>
|
||||||
{#snippet hint()}
|
{#snippet hint()}
|
||||||
<p>Se usa como nombre público de la pista en el catálogo.</p>
|
<p>Se usa como nombre público de la pista en el catálogo.</p>
|
||||||
<span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span>
|
<span class="shrink-0 tabular-nums">{title.length}/{LIMITS.trackTitle}</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user