From 1df7f6ccf62e9f5416817cabc1e2b40f2ac16866 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 05:19:41 +0200 Subject: [PATCH] web-player: share form limits between client and server and extract form parsers --- .../lib/components/ui/forms/AlbumForm.svelte | 9 ++-- .../components/ui/forms/PlaylistForm.svelte | 9 ++-- web-player/src/lib/config.ts | 1 - .../lib/server/{ => forms}/albumForm.test.ts | 2 +- .../src/lib/server/{ => forms}/albumForm.ts | 12 +++-- .../src/lib/server/forms/playlistForm.test.ts | 34 +++++++++++++ .../src/lib/server/forms/playlistForm.ts | 42 ++++++++++++++++ .../lib/server/forms/trackUploadForm.test.ts | 36 ++++++++++++++ .../src/lib/server/forms/trackUploadForm.ts | 30 ++++++++++++ web-player/src/lib/server/upload.ts | 17 +++++++ web-player/src/lib/utils/format.ts | 6 +++ web-player/src/lib/validation.ts | 9 ++++ .../src/routes/(app)/albums/+page.server.ts | 2 +- .../routes/(app)/albums/[id]/+page.server.ts | 20 +++----- .../routes/(app)/playlists/+page.server.ts | 48 +++++++------------ .../(app)/playlists/[id]/+page.server.ts | 36 ++++++-------- .../src/routes/(app)/upload/+page.server.ts | 31 +++--------- .../src/routes/(app)/upload/+page.svelte | 14 ++---- 18 files changed, 239 insertions(+), 119 deletions(-) rename web-player/src/lib/server/{ => forms}/albumForm.test.ts (96%) rename web-player/src/lib/server/{ => forms}/albumForm.ts (70%) create mode 100644 web-player/src/lib/server/forms/playlistForm.test.ts create mode 100644 web-player/src/lib/server/forms/playlistForm.ts create mode 100644 web-player/src/lib/server/forms/trackUploadForm.test.ts create mode 100644 web-player/src/lib/server/forms/trackUploadForm.ts create mode 100644 web-player/src/lib/validation.ts diff --git a/web-player/src/lib/components/ui/forms/AlbumForm.svelte b/web-player/src/lib/components/ui/forms/AlbumForm.svelte index 3442c45..9da82b3 100644 --- a/web-player/src/lib/components/ui/forms/AlbumForm.svelte +++ b/web-player/src/lib/components/ui/forms/AlbumForm.svelte @@ -5,7 +5,7 @@ import Field from '../primitives/Field.svelte'; import Input from '../primitives/Input.svelte'; import Textarea from '../primitives/Textarea.svelte'; - import { ALBUM_EARLIEST_YEAR } from '$lib/config'; + import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation'; interface Props { action: string; @@ -58,10 +58,13 @@ > {#snippet fields()} + {#snippet hint()} + {title.length}/{LIMITS.albumTitle} + {/snippet} diff --git a/web-player/src/lib/components/ui/forms/PlaylistForm.svelte b/web-player/src/lib/components/ui/forms/PlaylistForm.svelte index e298e74..e0b8663 100644 --- a/web-player/src/lib/components/ui/forms/PlaylistForm.svelte +++ b/web-player/src/lib/components/ui/forms/PlaylistForm.svelte @@ -7,8 +7,7 @@ import Textarea from '../primitives/Textarea.svelte'; import SegmentedControl from '../primitives/SegmentedControl.svelte'; - const NAME_MAX_LENGTH = 60; - const DESCRIPTION_MAX_LENGTH = 300; + import { LIMITS } from '$lib/validation'; interface Props { action: string; @@ -61,12 +60,12 @@ {#snippet hint()} - {name.length}/{NAME_MAX_LENGTH} + {name.length}/{LIMITS.playlistName} {/snippet} ): FormData { diff --git a/web-player/src/lib/server/albumForm.ts b/web-player/src/lib/server/forms/albumForm.ts similarity index 70% rename from web-player/src/lib/server/albumForm.ts rename to web-player/src/lib/server/forms/albumForm.ts index e3dfdf0..b4776a4 100644 --- a/web-player/src/lib/server/albumForm.ts +++ b/web-player/src/lib/server/forms/albumForm.ts @@ -1,5 +1,5 @@ import { formString } from '$lib/server/api'; -import { ALBUM_EARLIEST_YEAR } from '$lib/config'; +import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation'; interface AlbumFormBody { title: string; @@ -9,13 +9,15 @@ interface AlbumFormBody { export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } { const title = formString(form, 'title').trim(); - if (title === '' || title.length > 200) { - return { failMessage: 'El título es obligatorio (máx. 200 caracteres).' }; + if (title === '' || title.length > LIMITS.albumTitle) { + return { failMessage: `El título es obligatorio (máx. ${LIMITS.albumTitle} caracteres).` }; } const description = formString(form, 'description').trim(); - if (description.length > 256) { - return { failMessage: 'La descripción no puede pasar de 256 caracteres.' }; + if (description.length > LIMITS.albumDescription) { + return { + failMessage: `La descripción no puede pasar de ${LIMITS.albumDescription} caracteres.` + }; } const rawYear = formString(form, 'releaseYear').trim(); diff --git a/web-player/src/lib/server/forms/playlistForm.test.ts b/web-player/src/lib/server/forms/playlistForm.test.ts new file mode 100644 index 0000000..ff11961 --- /dev/null +++ b/web-player/src/lib/server/forms/playlistForm.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { LIMITS } from '$lib/validation'; +import { parsePlaylistForm } from './playlistForm'; + +function form(fields: Record): 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'); + }); +}); diff --git a/web-player/src/lib/server/forms/playlistForm.ts b/web-player/src/lib/server/forms/playlistForm.ts new file mode 100644 index 0000000..f89ee5e --- /dev/null +++ b/web-player/src/lib/server/forms/playlistForm.ts @@ -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') + } + }; +} diff --git a/web-player/src/lib/server/forms/trackUploadForm.test.ts b/web-player/src/lib/server/forms/trackUploadForm.test.ts new file mode 100644 index 0000000..a8fc0cd --- /dev/null +++ b/web-player/src/lib/server/forms/trackUploadForm.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { LIMITS } from '$lib/validation'; +import { parseTrackUploadForm } from './trackUploadForm'; + +function form(fields: Record): 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'); + }); +}); diff --git a/web-player/src/lib/server/forms/trackUploadForm.ts b/web-player/src/lib/server/forms/trackUploadForm.ts new file mode 100644 index 0000000..f1ef7e9 --- /dev/null +++ b/web-player/src/lib/server/forms/trackUploadForm.ts @@ -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' } }; +} diff --git a/web-player/src/lib/server/upload.ts b/web-player/src/lib/server/upload.ts index c21ce52..797a8b2 100644 --- a/web-player/src/lib/server/upload.ts +++ b/web-player/src/lib/server/upload.ts @@ -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 async function uploadPresignedImage( @@ -109,3 +111,18 @@ export async function uploadPresignedImage( 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 + ); +} diff --git a/web-player/src/lib/utils/format.ts b/web-player/src/lib/utils/format.ts index c983da3..fc82c36 100644 --- a/web-player/src/lib/utils/format.ts +++ b/web-player/src/lib/utils/format.ts @@ -36,6 +36,12 @@ export function albumMeta(releaseYear?: number | null, trackCount?: number): str .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 { return plural(trackCount, 'canción', 'canciones'); } diff --git a/web-player/src/lib/validation.ts b/web-player/src/lib/validation.ts new file mode 100644 index 0000000..f4cd8e0 --- /dev/null +++ b/web-player/src/lib/validation.ts @@ -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; diff --git a/web-player/src/routes/(app)/albums/+page.server.ts b/web-player/src/routes/(app)/albums/+page.server.ts index 692b1e3..9dd0d7d 100644 --- a/web-player/src/routes/(app)/albums/+page.server.ts +++ b/web-player/src/routes/(app)/albums/+page.server.ts @@ -3,7 +3,7 @@ import { fail, redirect } from '@sveltejs/kit'; import { apiFor, authedAction, formFile, requireUser, unwrapOrError } from '$lib/server/api'; import { toAlbum, toPage } from '$lib/server/mappers'; 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'; export const load: PageServerLoad = async ({ locals, url, fetch }) => { diff --git a/web-player/src/routes/(app)/albums/[id]/+page.server.ts b/web-player/src/routes/(app)/albums/[id]/+page.server.ts index 8b494e6..4e6864e 100644 --- a/web-player/src/routes/(app)/albums/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/albums/[id]/+page.server.ts @@ -11,8 +11,8 @@ import { } from '$lib/server/api'; import { toAlbum, toTrack } from '$lib/server/mappers'; import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config'; -import { parseAlbumForm } from '$lib/server/albumForm'; -import { uploadPresignedImage } from '$lib/server/upload'; +import { parseAlbumForm } from '$lib/server/forms/albumForm'; +import { uploadOptionalCover } from '$lib/server/upload'; export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { const { allowAnonymousListening } = await parent(); @@ -75,17 +75,9 @@ export const actions: Actions = { const parsed = parseAlbumForm(form); if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); - let newPictureIntentId: string | null = null; - const cover = formFile(form, 'cover'); - if (cover) { - 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 cover = await uploadOptionalCover(api, '/albums/upload-picture', formFile(form, 'cover')); + if ('failMessage' in cover) { + return fail(502, { message: cover.failMessage, detail: cover.detail }); } const result = await api.PUT('/albums/{albumId}', { @@ -94,7 +86,7 @@ export const actions: Actions = { newTitle: parsed.body.title, newDescription: parsed.body.description, newReleaseYear: parsed.body.releaseYear, - newPictureIntentId + newPictureIntentId: cover.intentId } }); return failOnError(result, 'No se pudo actualizar el álbum.') ?? { edited: true }; diff --git a/web-player/src/routes/(app)/playlists/+page.server.ts b/web-player/src/routes/(app)/playlists/+page.server.ts index 54c9f25..6538e5e 100644 --- a/web-player/src/routes/(app)/playlists/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/+page.server.ts @@ -1,18 +1,10 @@ import type { PageServerLoad, Actions } from './$types'; import { fail, redirect } from '@sveltejs/kit'; -import { - apiFor, - authedAction, - formFile, - formString, - requireUser, - unwrapOrError -} from '$lib/server/api'; +import { apiFor, authedAction, requireUser, unwrapOrError } from '$lib/server/api'; import { PLAYLISTS_PAGE_SIZE, PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } from '$lib/config'; import { toPlaylistSummary, toTrack } from '$lib/server/mappers'; -import { uploadPresignedImage } from '$lib/server/upload'; - -const MAX_NAME = 100; +import { parsePlaylistForm } from '$lib/server/forms/playlistForm'; +import { uploadOptionalCover } from '$lib/server/upload'; export const load: PageServerLoad = async ({ locals, url, fetch }) => { const user = requireUser(locals, url); @@ -51,33 +43,25 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const actions: Actions = { create: authedAction(async ({ api, form }) => { - const name = formString(form, 'name').trim(); - const description = formString(form, 'description').trim(); - const visibility = formString(form, 'visibility') === 'public' ? 'Public' : 'Private'; + const parsed = parsePlaylistForm(form); + if ('failMessage' in parsed) { + 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) { - return fail(400, { - message: 'El nombre es obligatorio (máx. 100 caracteres).', + const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover); + if ('failMessage' in uploaded) { + return fail(502, { + message: uploaded.failMessage, 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', { - body: { name, description, pictureIntentId, visibility } + body: { name, description, pictureIntentId: uploaded.intentId, visibility } }); if (error || !data) diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts index 797d354..44f11ba 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts @@ -4,16 +4,14 @@ import { apiFor, authedAction, failOnError, - formFile, formString, optionalUser, unwrapOrError } from '$lib/server/api'; import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config'; import { toDatedTrack, toPlaylist } from '$lib/server/mappers'; -import { uploadPresignedImage } from '$lib/server/upload'; - -const MAX_NAME = 100; +import { parsePlaylistForm } from '$lib/server/forms/playlistForm'; +import { uploadOptionalCover } from '$lib/server/upload'; export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { const { allowAnonymousListening } = await parent(); @@ -50,29 +48,23 @@ export const actions: Actions = { }), edit: authedAction(async ({ api, form, params }) => { - const name = formString(form, 'name').trim(); - const description = formString(form, 'description').trim(); - const newVisibility = formString(form, 'visibility') === 'public' ? 'Public' : 'Private'; - if (name === '' || name.length > MAX_NAME) { - return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' }); - } + const parsed = parsePlaylistForm(form); + if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); + const { name, description, visibility, cover } = parsed.body; - let newPictureIntentId: 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, detail: result.detail }); - } - newPictureIntentId = result.intentId; + const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover); + if ('failMessage' in uploaded) { + return fail(502, { message: uploaded.failMessage, detail: uploaded.detail }); } const result = await api.PUT('/playlists/{playlistId}', { 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 }; }), diff --git a/web-player/src/routes/(app)/upload/+page.server.ts b/web-player/src/routes/(app)/upload/+page.server.ts index 5454749..2c53046 100644 --- a/web-player/src/routes/(app)/upload/+page.server.ts +++ b/web-player/src/routes/(app)/upload/+page.server.ts @@ -1,21 +1,13 @@ import type { Actions, PageServerLoad } from './$types'; import { fail } from '@sveltejs/kit'; -import { - apiFor, - authedAction, - formFile, - formString, - requireData, - requireUser -} from '$lib/server/api'; +import { apiFor, authedAction, requireData, requireUser } from '$lib/server/api'; +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']; -const MAX_TITLE = 100; - export const load: PageServerLoad = async ({ locals, url, fetch }) => { requireUser(locals, url); const api = apiFor({ fetch, locals }); @@ -25,28 +17,17 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const actions: Actions = { default: authedAction(async ({ api, form }) => { - const title = formString(form, 'title').trim(); - const audio = formFile(form, 'audio'); - const cover = formFile(form, 'cover'); - const rawTags = form.getAll('tags').map(String); - const isExplicit = form.get('isExplicit') === 'on'; + const parsed = parseTrackUploadForm(form); + if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); + const { title, audio, cover, tags: rawTags, isExplicit } = parsed.body; - 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 options = available ?? []; const known = new Set(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 = [...new Set(rawTags)] as Genre[]; + const tags = rawTags as Genre[]; const conflict = findConflict(tags, options); if (conflict) { return fail(400, { diff --git a/web-player/src/routes/(app)/upload/+page.svelte b/web-player/src/routes/(app)/upload/+page.svelte index 89ecc0a..a5eedd2 100644 --- a/web-player/src/routes/(app)/upload/+page.svelte +++ b/web-player/src/routes/(app)/upload/+page.svelte @@ -15,13 +15,13 @@ import Button from '$lib/components/ui/primitives/Button.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 { formatSize } from '$lib/utils/format'; import { genreOptions } from '$lib/data/genres'; type Status = 'idle' | 'uploading' | 'done' | 'error'; type StepState = 'idle' | 'active' | 'done'; - const MAX_TITLE = 100; - let { data } = $props(); let title = $state(''); @@ -70,12 +70,6 @@ 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) { audioName = file.name; audioSize = file.size; @@ -256,12 +250,12 @@ id="title" name="title" bind:value={title} - maxlength={MAX_TITLE} + maxlength={LIMITS.trackTitle} placeholder="Nombre de la canción" /> {#snippet hint()}

Se usa como nombre público de la pista en el catálogo.

- {title.length}/{MAX_TITLE} + {title.length}/{LIMITS.trackTitle} {/snippet}