web-player: forms (CoverForm, SegmentedControl, Chip)

CoverForm.svelte is the shared skeleton (form + enhance + cover +
error message + buttons) for AlbumForm and PlaylistForm, which now
only keep their own fields via the `fields` snippet (next to the
cover) and `extra` (full width — not literally in the spec, but
needed: the album description and the playlist visibility picker
don't fit in the column next to the art).

ImageDropzone gains `size="hero"` (size-cover-hero + rounded-art-lg)
and an `onselect` callback; the upload-track page now uses this
component instead of reimplementing the cover picker by hand (wrapped
in {#key} so it can reset after publishing). Its empty-state icon
goes from 64px to icon-xl (36px), as the plan recommends.

SegmentedControl.svelte (generic, N options) replaces PlaylistForm's
Private/Public picker. Chip.svelte replaces Explore's filter chips
(selected/count) and the suggested-genre chips in "No results" (href,
outline style).

@utility field-control in layout.css unifies the repeated class
string shared by Input and Textarea.

Verified with npm run check/build; no authenticated visual QA (Docker
unavailable this session).
This commit is contained in:
2026-09-21 00:58:52 +02:00
parent 1bdd7ec9f1
commit 8975e43729
12 changed files with 304 additions and 234 deletions
@@ -1,13 +1,10 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Disc3 from '@lucide/svelte/icons/disc-3';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte';
import CoverForm from './CoverForm.svelte';
import Field from './Field.svelte';
import Input from './Input.svelte';
import Textarea from './Textarea.svelte';
import Alert from './Alert.svelte';
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
interface Props {
@@ -43,81 +40,55 @@
let title = $state(untrack(() => initialTitle));
let description = $state(untrack(() => initialDescription));
let releaseYear = $state(untrack(() => (initialReleaseYear ? String(initialReleaseYear) : '')));
let submitting = $state(false);
const latestYear = new Date().getFullYear() + 1;
</script>
<form
method="POST"
<CoverForm
{action}
enctype="multipart/form-data"
use:enhance={() => {
submitting = true;
return async ({ update, result }) => {
await update({ reset: false });
submitting = false;
if (result.type === 'success') onSuccess?.();
};
}}
class="mt-5 w-full max-w-2xl space-y-5"
{coverFallbackUrl}
{requireCover}
coverIcon={Disc3}
{formMessage}
{submitLabel}
{submittingLabel}
canSubmit={title.trim() !== ''}
{onCancel}
{onSuccess}
>
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
<Field>
<ImageDropzone
name="cover"
fallbackUrl={coverFallbackUrl}
required={requireCover}
icon={Disc3}
gradient
class="h-36 w-36 rounded-2xl"
{#snippet fields()}
<Field label="Título" for="album-title">
<Input
id="album-title"
name="title"
maxlength={200}
required
bind:value={title}
placeholder={titlePlaceholder}
/>
</Field>
<div class="flex flex-1 flex-col gap-4">
<Field label="Título" for="album-title">
<Input
id="album-title"
name="title"
maxlength={200}
required
bind:value={title}
placeholder={titlePlaceholder}
/>
</Field>
<Field label="Año de publicación" for="album-year">
<Input
id="album-year"
name="releaseYear"
type="number"
min={ALBUM_EARLIEST_YEAR}
max={latestYear}
bind:value={releaseYear}
placeholder={String(new Date().getFullYear())}
/>
</Field>
</div>
</div>
<Field label="Descripción" for="album-description">
<Textarea
id="album-description"
name="description"
maxlength={256}
bind:value={description}
placeholder="¿De qué va este álbum?"
/>
</Field>
{#if formMessage}
<Alert tone="danger">{formMessage}</Alert>
{/if}
<div class="flex items-center justify-end gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || title.trim() === ''}>
{submitting ? submittingLabel : submitLabel}
</Button>
</div>
</form>
<Field label="Año de publicación" for="album-year">
<Input
id="album-year"
name="releaseYear"
type="number"
min={ALBUM_EARLIEST_YEAR}
max={latestYear}
bind:value={releaseYear}
placeholder={String(new Date().getFullYear())}
/>
</Field>
{/snippet}
{#snippet extra()}
<Field label="Descripción" for="album-description">
<Textarea
id="album-description"
name="description"
maxlength={256}
bind:value={description}
placeholder="¿De qué va este álbum?"
/>
</Field>
{/snippet}
</CoverForm>