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
@@ -8,6 +8,8 @@
required?: boolean;
icon?: LucideIcon;
gradient?: boolean;
size?: 'default' | 'hero';
onselect?: (file: File) => void;
class?: string;
}
@@ -17,9 +19,16 @@
required = false,
icon: Icon = ImageIcon,
gradient = false,
class: klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control'
size = 'default',
onselect,
class: klass = ''
}: Props = $props();
const SIZE: Record<'default' | 'hero', string> = {
default: 'h-40 w-40 sm:h-52 sm:w-52 rounded-control',
hero: 'size-cover-hero rounded-art-lg'
};
let preview = $state('');
let fallbackFailed = $state(false);
@@ -27,14 +36,16 @@
function onInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) preview = URL.createObjectURL(file);
if (!file) return;
preview = URL.createObjectURL(file);
onselect?.(file);
}
</script>
<label
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {gradient
? ''
: 'border border-line bg-surface hover:border-accent/50'} {klass}"
: 'border border-line bg-surface hover:border-accent/50'} {SIZE[size]} {klass}"
style={gradient && !hasImage ? 'background:var(--mf-cover-grad)' : undefined}
>
<input
@@ -57,6 +68,6 @@
class="h-full w-full object-cover"
/>
{:else}
<Icon class="h-9 w-9 {gradient ? 'text-on-art/70' : 'text-muted'}" strokeWidth={1.3} />
<Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-muted'}" strokeWidth={1.3} />
{/if}
</label>