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
@@ -13,6 +13,7 @@
import PlayButton from '$lib/components/ui/PlayButton.svelte';
import EqBars from '$lib/components/ui/EqBars.svelte';
import ListRow from '$lib/components/ui/ListRow.svelte';
import Chip from '$lib/components/ui/Chip.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { EXPLORE_PAGE_SIZE } from '$lib/config';
import { searchHref } from '$lib/navigation.svelte';
@@ -262,19 +263,13 @@
<div class="mb-7 flex flex-wrap gap-1.75 sm:mb-8">
{#each searchChips as chip (chip.label)}
<button
type="button"
<Chip
selected={sfilter === chip.label}
count={chip.count}
onclick={() => (sfilter = chip.label)}
class="flex items-center gap-1.75 rounded-full px-3.5 py-1.75 text-xs font-medium transition {sfilter ===
chip.label
? 'bg-cta-strong text-ink'
: 'bg-surface-2 text-fg-2 hover:bg-surface-hover hover:text-fg'}"
>
<span>{chip.label}</span>
<span class="text-xs tabular-nums {sfilter === chip.label ? 'opacity-55' : 'text-muted'}"
>{chip.count}</span
>
</button>
{chip.label}
</Chip>
{/each}
</div>
@@ -361,12 +356,7 @@
</p>
<div class="mt-5.5 flex flex-wrap justify-center gap-2">
{#each genreTiles.slice(0, 4) as genre (genre.query)}
<a
href={searchHref(genre.query)}
class="rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg"
>
{genre.label}
</a>
<Chip href={searchHref(genre.query)}>{genre.label}</Chip>
{/each}
</div>
</div>
+13 -24
View File
@@ -7,6 +7,7 @@
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import Surface from '$lib/components/ui/Surface.svelte';
import ImageDropzone from '$lib/components/ui/ImageDropzone.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import Field from '$lib/components/ui/Field.svelte';
import Input from '$lib/components/ui/Input.svelte';
@@ -21,7 +22,8 @@
let title = $state('');
let audioName = $state('');
let audioSize = $state(0);
let coverPreview = $state('');
let hasCover = $state(false);
let coverResetToken = $state(0);
let dragging = $state(false);
let status = $state<Status>('idle');
let acceptedTerms = $state(false);
@@ -38,7 +40,7 @@
const busy = $derived(status === 'uploading');
const canPublish = $derived(
title.trim() !== '' && audioName !== '' && coverPreview !== '' && acceptedTerms && !busy
title.trim() !== '' && audioName !== '' && hasCover && acceptedTerms && !busy
);
const stepStates = $derived.by<StepState[]>(() => {
@@ -79,16 +81,12 @@
pickAudio(file);
}
function onCoverInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) coverPreview = URL.createObjectURL(file);
}
function reset() {
title = '';
audioName = '';
audioSize = 0;
coverPreview = '';
hasCover = false;
coverResetToken += 1;
status = 'idle';
acceptedTerms = false;
errorMsg = '';
@@ -213,24 +211,15 @@
<div class="grid items-start gap-6 sm:grid-cols-[auto_1fr] sm:gap-8">
<Field label="Portada">
<label
class="relative grid h-36 w-36 cursor-pointer place-items-center overflow-hidden rounded-control transition sm:h-44 sm:w-44"
style={coverPreview === '' ? 'background:var(--mf-cover-grad)' : undefined}
>
<input
type="file"
{#key coverResetToken}
<ImageDropzone
name="cover"
accept="image/*"
onchange={onCoverInput}
class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar portada"
icon={ImageIcon}
gradient
size="hero"
onselect={() => (hasCover = true)}
/>
{#if coverPreview !== ''}
<img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
{:else}
<ImageIcon class="h-16 w-16 text-on-art/60" strokeWidth={1} />
{/if}
</label>
{/key}
</Field>
<Field label="Título" for="title">