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 -1
View File
@@ -783,7 +783,7 @@ Tareas:
| P8 | ✅ | (pendiente) | Decisión tomada (recomendada por el propio plan, sin bloquear): se elimina la columna "Álbum" que siempre mostraba "—". TrackList unifica TrackTable+TrackRow+TrackIndexCell+TrackMeta; AlbumTrackTable/PlaylistTrackTable/LibraryPicker se inlinean en sus páginas y se borran. Sin QA visual autenticada (Docker no disponible) | | P8 | ✅ | (pendiente) | Decisión tomada (recomendada por el propio plan, sin bloquear): se elimina la columna "Álbum" que siempre mostraba "—". TrackList unifica TrackTable+TrackRow+TrackIndexCell+TrackMeta; AlbumTrackTable/PlaylistTrackTable/LibraryPicker se inlinean en sus páginas y se borran. Sin QA visual autenticada (Docker no disponible) |
| P9 | ✅ | (pendiente) | MediaCard gana hover-zoom del arte y scrim+PlayButton condicionados a `onPlay` (antes exclusivos de MixTile); ahora se aplicarían a cualquier card futura con onPlay, consistencia intencional. Sin QA visual autenticada (Docker no disponible) | | P9 | ✅ | (pendiente) | MediaCard gana hover-zoom del arte y scrim+PlayButton condicionados a `onPlay` (antes exclusivos de MixTile); ahora se aplicarían a cualquier card futura con onPlay, consistencia intencional. Sin QA visual autenticada (Docker no disponible) |
| P10 | ✅ | (pendiente) | Decisión tomada (recomendada, sin bloquear): iconos en los botones de cabecera de playlist (antes solo álbum tenía). PlaylistHeader pierde su prop `id` sin usar tras inlinearlo. Explorar: la cabecera de "Resultados para…" ahora usa el `mb-7/8` de PageHeader en vez de un `mt-5` ajustado a mano antes de los chips de filtro — gap ligeramente mayor entre el resumen y los chips (Δ menor, documentado). Sin QA visual autenticada (Docker no disponible) | | P10 | ✅ | (pendiente) | Decisión tomada (recomendada, sin bloquear): iconos en los botones de cabecera de playlist (antes solo álbum tenía). PlaylistHeader pierde su prop `id` sin usar tras inlinearlo. Explorar: la cabecera de "Resultados para…" ahora usa el `mb-7/8` de PageHeader en vez de un `mt-5` ajustado a mano antes de los chips de filtro — gap ligeramente mayor entre el resumen y los chips (Δ menor, documentado). Sin QA visual autenticada (Docker no disponible) |
| P11 | ⬜ | | | | P11 | ✅ | (pendiente) | CoverForm añade snippet `extra` (no está en la spec literal, solo `fields`) para el contenido a ancho completo bajo la fila portada+campos (descripción de álbum, selector de visibilidad de playlist) que no cabía en una única columna. Upload usa ImageDropzone con `onselect` (prop nueva) y se envuelve en `{#key}` para poder resetear tras publicar — Δ: el contenedor de portada pasa de 144/176px (móvil/desktop) a 144px fijo (size-cover-hero, sin variante desktop mayor), y el icono de posición vacía de 64px a 36px (icon-xl), tal como recomienda el plan. Sin QA visual autenticada (Docker no disponible) |
| P12 | ⬜ | | | | P12 | ⬜ | | |
| P13 | ⬜ | | | | P13 | ⬜ | | |
| P14 | ⬜ | | | | P14 | ⬜ | | |
@@ -1,13 +1,10 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Disc3 from '@lucide/svelte/icons/disc-3'; import Disc3 from '@lucide/svelte/icons/disc-3';
import ImageDropzone from './ImageDropzone.svelte'; import CoverForm from './CoverForm.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte'; import Field from './Field.svelte';
import Input from './Input.svelte'; import Input from './Input.svelte';
import Textarea from './Textarea.svelte'; import Textarea from './Textarea.svelte';
import Alert from './Alert.svelte';
import { ALBUM_EARLIEST_YEAR } from '$lib/config'; import { ALBUM_EARLIEST_YEAR } from '$lib/config';
interface Props { interface Props {
@@ -43,81 +40,55 @@
let title = $state(untrack(() => initialTitle)); let title = $state(untrack(() => initialTitle));
let description = $state(untrack(() => initialDescription)); let description = $state(untrack(() => initialDescription));
let releaseYear = $state(untrack(() => (initialReleaseYear ? String(initialReleaseYear) : ''))); let releaseYear = $state(untrack(() => (initialReleaseYear ? String(initialReleaseYear) : '')));
let submitting = $state(false);
const latestYear = new Date().getFullYear() + 1; const latestYear = new Date().getFullYear() + 1;
</script> </script>
<form <CoverForm
method="POST"
{action} {action}
enctype="multipart/form-data" {coverFallbackUrl}
use:enhance={() => { {requireCover}
submitting = true; coverIcon={Disc3}
return async ({ update, result }) => { {formMessage}
await update({ reset: false }); {submitLabel}
submitting = false; {submittingLabel}
if (result.type === 'success') onSuccess?.(); canSubmit={title.trim() !== ''}
}; {onCancel}
}} {onSuccess}
class="mt-5 w-full max-w-2xl space-y-5"
> >
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8"> {#snippet fields()}
<Field> <Field label="Título" for="album-title">
<ImageDropzone <Input
name="cover" id="album-title"
fallbackUrl={coverFallbackUrl} name="title"
required={requireCover} maxlength={200}
icon={Disc3} required
gradient bind:value={title}
class="h-36 w-36 rounded-2xl" placeholder={titlePlaceholder}
/> />
</Field> </Field>
<div class="flex flex-1 flex-col gap-4"> <Field label="Año de publicación" for="album-year">
<Field label="Título" for="album-title"> <Input
<Input id="album-year"
id="album-title" name="releaseYear"
name="title" type="number"
maxlength={200} min={ALBUM_EARLIEST_YEAR}
required max={latestYear}
bind:value={title} bind:value={releaseYear}
placeholder={titlePlaceholder} placeholder={String(new Date().getFullYear())}
/> />
</Field> </Field>
{/snippet}
<Field label="Año de publicación" for="album-year"> {#snippet extra()}
<Input <Field label="Descripción" for="album-description">
id="album-year" <Textarea
name="releaseYear" id="album-description"
type="number" name="description"
min={ALBUM_EARLIEST_YEAR} maxlength={256}
max={latestYear} bind:value={description}
bind:value={releaseYear} placeholder="¿De qué va este álbum?"
placeholder={String(new Date().getFullYear())} />
/> </Field>
</Field> {/snippet}
</div> </CoverForm>
</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>
@@ -0,0 +1,36 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
selected?: boolean;
count?: number;
href?: string;
onclick?: () => void;
children: Snippet;
}
let { selected = false, count, href, onclick, children }: Props = $props();
const classes = $derived(
href
? 'rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg'
: `flex items-center gap-1.75 rounded-full px-3.5 py-1.75 text-xs font-medium transition ${
selected
? 'bg-cta-strong text-ink'
: 'bg-surface-2 text-fg-2 hover:bg-surface-hover hover:text-fg'
}`
);
</script>
{#snippet content()}
{@render children()}
{#if count !== undefined}
<span class="tabular-nums {selected ? 'opacity-55' : 'text-muted'}">{count}</span>
{/if}
{/snippet}
{#if href}
<a {href} class={classes}>{@render content()}</a>
{:else}
<button type="button" {onclick} class={classes}>{@render content()}</button>
{/if}
@@ -0,0 +1,94 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { enhance } from '$app/forms';
import type { LucideIcon } from '@lucide/svelte';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte';
import Alert from './Alert.svelte';
interface Props {
action: string;
coverFallbackUrl?: string;
coverIcon: LucideIcon;
requireCover?: boolean;
formMessage?: string;
submitLabel: string;
submittingLabel: string;
canSubmit: boolean;
helperText?: string;
onCancel: () => void;
onSuccess?: () => void;
fields: Snippet;
extra?: Snippet;
}
let {
action,
coverFallbackUrl,
coverIcon,
requireCover = false,
formMessage,
submitLabel,
submittingLabel,
canSubmit,
helperText,
onCancel,
onSuccess,
fields,
extra
}: Props = $props();
let submitting = $state(false);
</script>
<form
method="POST"
{action}
enctype="multipart/form-data"
use:enhance={() => {
submitting = true;
return async ({ update, result }) => {
await update({ reset: false });
submitting = false;
if (result.type === 'success' || result.type === 'redirect') onSuccess?.();
};
}}
class="mt-5 w-full max-w-4xl space-y-5"
>
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
<Field>
<ImageDropzone
name="cover"
fallbackUrl={coverFallbackUrl}
required={requireCover}
icon={coverIcon}
gradient
size="hero"
/>
</Field>
<div class="flex flex-1 flex-col gap-4">
{@render fields()}
</div>
</div>
{@render extra?.()}
{#if formMessage}
<Alert tone="danger">{formMessage}</Alert>
{/if}
<div class="flex items-center justify-between gap-4">
{#if helperText}
<p class="text-sm text-muted">{helperText}</p>
{:else}
<span></span>
{/if}
<div class="flex shrink-0 items-center gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || !canSubmit}>
{submitting ? submittingLabel : submitLabel}
</Button>
</div>
</div>
</form>
@@ -8,6 +8,8 @@
required?: boolean; required?: boolean;
icon?: LucideIcon; icon?: LucideIcon;
gradient?: boolean; gradient?: boolean;
size?: 'default' | 'hero';
onselect?: (file: File) => void;
class?: string; class?: string;
} }
@@ -17,9 +19,16 @@
required = false, required = false,
icon: Icon = ImageIcon, icon: Icon = ImageIcon,
gradient = false, gradient = false,
class: klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control' size = 'default',
onselect,
class: klass = ''
}: Props = $props(); }: 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 preview = $state('');
let fallbackFailed = $state(false); let fallbackFailed = $state(false);
@@ -27,14 +36,16 @@
function onInput(event: Event) { function onInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0]; const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) preview = URL.createObjectURL(file); if (!file) return;
preview = URL.createObjectURL(file);
onselect?.(file);
} }
</script> </script>
<label <label
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {gradient 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} style={gradient && !hasImage ? 'background:var(--mf-cover-grad)' : undefined}
> >
<input <input
@@ -57,6 +68,6 @@
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else} {: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} {/if}
</label> </label>
@@ -37,7 +37,7 @@
<div class="relative w-full"> <div class="relative w-full">
{#if Icon} {#if Icon}
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted"> <span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted">
<Icon class="h-5 w-5" /> <Icon class="size-icon-lg" />
</span> </span>
{/if} {/if}
<input <input
@@ -52,8 +52,6 @@
{autocomplete} {autocomplete}
{oninput} {oninput}
bind:value bind:value
class="w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {Icon class="field-control {Icon ? 'pr-4 pl-11' : 'px-4'} {klass}"
? 'pr-4 pl-11'
: 'px-4'} {klass}"
/> />
</div> </div>
@@ -1,13 +1,11 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Music from '@lucide/svelte/icons/music'; import Music from '@lucide/svelte/icons/music';
import ImageDropzone from './ImageDropzone.svelte'; import CoverForm from './CoverForm.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte'; import Field from './Field.svelte';
import Input from './Input.svelte'; import Input from './Input.svelte';
import Textarea from './Textarea.svelte'; import Textarea from './Textarea.svelte';
import Alert from './Alert.svelte'; import SegmentedControl from './SegmentedControl.svelte';
const NAME_MAX_LENGTH = 60; const NAME_MAX_LENGTH = 60;
const DESCRIPTION_MAX_LENGTH = 300; const DESCRIPTION_MAX_LENGTH = 300;
@@ -44,118 +42,65 @@
let name = $state(untrack(() => initialName)); let name = $state(untrack(() => initialName));
let description = $state(untrack(() => initialDescription)); let description = $state(untrack(() => initialDescription));
let submitting = $state(false);
let visibility = $state(untrack(() => initialVisibility)); let visibility = $state(untrack(() => initialVisibility));
</script> </script>
<form <CoverForm
method="POST"
{action} {action}
enctype="multipart/form-data" {coverFallbackUrl}
use:enhance={() => { coverIcon={Music}
submitting = true; {formMessage}
return async ({ update, result }) => { {submitLabel}
await update({ reset: false }); {submittingLabel}
submitting = false; canSubmit={name.trim() !== ''}
if (result.type === 'success' || result.type === 'redirect') onSuccess?.(); {helperText}
}; {onCancel}
}} {onSuccess}
class="mt-5 w-full max-w-4xl space-y-5"
> >
<input type="hidden" name="visibility" value={visibility} /> {#snippet fields()}
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8"> <input type="hidden" name="visibility" value={visibility} />
<Field> <Field label="Nombre" for="playlist-name">
<ImageDropzone {#snippet hint()}
name="cover" <span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
fallbackUrl={coverFallbackUrl} {/snippet}
icon={Music} <Input
gradient id="playlist-name"
class="h-36 w-36 rounded-2xl" name="name"
maxlength={NAME_MAX_LENGTH}
required
bind:value={name}
placeholder={namePlaceholder}
/> />
</Field> </Field>
<div class="flex flex-1 flex-col gap-2"> <Field label="Descripción" for="playlist-description" class="min-h-0 flex-1">
<Field label="Nombre" for="playlist-name"> <Textarea
{#snippet hint()} id="playlist-description"
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span> name="description"
{/snippet} maxlength={DESCRIPTION_MAX_LENGTH}
<Input bind:value={description}
id="playlist-name" placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
name="name" class="flex-1"
maxlength={NAME_MAX_LENGTH} />
required </Field>
bind:value={name} {/snippet}
placeholder={namePlaceholder} {#snippet extra()}
/> <div class="flex items-center justify-between gap-4 rounded-art bg-surface px-4 py-3.5">
</Field> <div class="min-w-0">
<div class="text-sm font-medium text-fg-2">Visibilidad</div>
<Field label="Descripción" for="playlist-description" class="min-h-0 flex-1"> <div class="mt-0.5 text-xs text-muted">
<Textarea {visibility === 'private'
id="playlist-description" ? 'Solo tú la ves en tu biblioteca'
name="description" : 'Cualquiera con el enlace puede verla'}
maxlength={DESCRIPTION_MAX_LENGTH} </div>
bind:value={description}
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
class="flex-1"
/>
</Field>
</div>
</div>
<div class="flex items-center justify-between gap-4 rounded-art bg-surface px-4 py-3.5">
<div class="min-w-0">
<div class="text-sm font-medium text-fg-2">Visibilidad</div>
<div class="mt-0.5 text-xs text-muted">
{visibility === 'private'
? 'Solo tú la ves en tu biblioteca'
: 'Cualquiera con el enlace puede verla'}
</div> </div>
<SegmentedControl
bind:value={visibility}
options={[
{ value: 'private', label: 'Privada' },
{ value: 'public', label: 'Pública' }
]}
/>
</div> </div>
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1"> {/snippet}
<div </CoverForm>
class="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full bg-fg transition-transform duration-300 ease-out {visibility ===
'public'
? 'translate-x-full'
: ''}"
></div>
<button
type="button"
onclick={() => (visibility = 'private')}
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-medium transition-colors {visibility ===
'private'
? 'text-ink'
: 'text-muted hover:text-fg-2'}"
>
Privada
</button>
<button
type="button"
onclick={() => (visibility = 'public')}
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-medium transition-colors {visibility ===
'public'
? 'text-ink'
: 'text-muted hover:text-fg-2'}"
>
Pública
</button>
</div>
</div>
{#if formMessage}
<Alert tone="danger">{formMessage}</Alert>
{/if}
<div class="flex items-center justify-between gap-4">
{#if helperText}
<p class="text-sm text-muted">{helperText}</p>
{:else}
<span></span>
{/if}
<div class="flex shrink-0 items-center gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || name.trim() === ''}>
{submitting ? submittingLabel : submitLabel}
</Button>
</div>
</div>
</form>
@@ -0,0 +1,34 @@
<script lang="ts" generics="T extends string">
interface Option<T> {
value: T;
label: string;
}
interface Props {
options: Option<T>[];
value: T;
}
let { options, value = $bindable() }: Props = $props();
const index = $derived(options.findIndex((option) => option.value === value));
</script>
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1">
<div
class="absolute inset-y-1 left-1 rounded-full bg-fg transition-transform duration-300 ease-out"
style="width:calc((100% - 0.5rem) / {options.length}); transform:translateX(calc({index} * 100%))"
></div>
{#each options as option (option.value)}
<button
type="button"
onclick={() => (value = option.value)}
class="relative z-10 flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value ===
option.value
? 'text-ink'
: 'text-muted hover:text-fg-2'}"
>
{option.label}
</button>
{/each}
</div>
@@ -24,5 +24,4 @@
{placeholder} {placeholder}
{maxlength} {maxlength}
bind:value bind:value
class="min-h-0 w-full resize-none rounded-control bg-surface px-4 py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {klass}" class="min-h-0 field-control resize-none px-4 {klass}"></textarea>
></textarea>
@@ -13,6 +13,7 @@
import PlayButton from '$lib/components/ui/PlayButton.svelte'; import PlayButton from '$lib/components/ui/PlayButton.svelte';
import EqBars from '$lib/components/ui/EqBars.svelte'; import EqBars from '$lib/components/ui/EqBars.svelte';
import ListRow from '$lib/components/ui/ListRow.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 SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { EXPLORE_PAGE_SIZE } from '$lib/config'; import { EXPLORE_PAGE_SIZE } from '$lib/config';
import { searchHref } from '$lib/navigation.svelte'; import { searchHref } from '$lib/navigation.svelte';
@@ -262,19 +263,13 @@
<div class="mb-7 flex flex-wrap gap-1.75 sm:mb-8"> <div class="mb-7 flex flex-wrap gap-1.75 sm:mb-8">
{#each searchChips as chip (chip.label)} {#each searchChips as chip (chip.label)}
<button <Chip
type="button" selected={sfilter === chip.label}
count={chip.count}
onclick={() => (sfilter = chip.label)} 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> {chip.label}
<span class="text-xs tabular-nums {sfilter === chip.label ? 'opacity-55' : 'text-muted'}" </Chip>
>{chip.count}</span
>
</button>
{/each} {/each}
</div> </div>
@@ -361,12 +356,7 @@
</p> </p>
<div class="mt-5.5 flex flex-wrap justify-center gap-2"> <div class="mt-5.5 flex flex-wrap justify-center gap-2">
{#each genreTiles.slice(0, 4) as genre (genre.query)} {#each genreTiles.slice(0, 4) as genre (genre.query)}
<a <Chip href={searchHref(genre.query)}>{genre.label}</Chip>
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>
{/each} {/each}
</div> </div>
</div> </div>
+13 -24
View File
@@ -7,6 +7,7 @@
import Page from '$lib/components/ui/Page.svelte'; import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte'; import PageHeader from '$lib/components/ui/PageHeader.svelte';
import Surface from '$lib/components/ui/Surface.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 Alert from '$lib/components/ui/Alert.svelte';
import Field from '$lib/components/ui/Field.svelte'; import Field from '$lib/components/ui/Field.svelte';
import Input from '$lib/components/ui/Input.svelte'; import Input from '$lib/components/ui/Input.svelte';
@@ -21,7 +22,8 @@
let title = $state(''); let title = $state('');
let audioName = $state(''); let audioName = $state('');
let audioSize = $state(0); let audioSize = $state(0);
let coverPreview = $state(''); let hasCover = $state(false);
let coverResetToken = $state(0);
let dragging = $state(false); let dragging = $state(false);
let status = $state<Status>('idle'); let status = $state<Status>('idle');
let acceptedTerms = $state(false); let acceptedTerms = $state(false);
@@ -38,7 +40,7 @@
const busy = $derived(status === 'uploading'); const busy = $derived(status === 'uploading');
const canPublish = $derived( const canPublish = $derived(
title.trim() !== '' && audioName !== '' && coverPreview !== '' && acceptedTerms && !busy title.trim() !== '' && audioName !== '' && hasCover && acceptedTerms && !busy
); );
const stepStates = $derived.by<StepState[]>(() => { const stepStates = $derived.by<StepState[]>(() => {
@@ -79,16 +81,12 @@
pickAudio(file); pickAudio(file);
} }
function onCoverInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) coverPreview = URL.createObjectURL(file);
}
function reset() { function reset() {
title = ''; title = '';
audioName = ''; audioName = '';
audioSize = 0; audioSize = 0;
coverPreview = ''; hasCover = false;
coverResetToken += 1;
status = 'idle'; status = 'idle';
acceptedTerms = false; acceptedTerms = false;
errorMsg = ''; errorMsg = '';
@@ -213,24 +211,15 @@
<div class="grid items-start gap-6 sm:grid-cols-[auto_1fr] sm:gap-8"> <div class="grid items-start gap-6 sm:grid-cols-[auto_1fr] sm:gap-8">
<Field label="Portada"> <Field label="Portada">
<label {#key coverResetToken}
class="relative grid h-36 w-36 cursor-pointer place-items-center overflow-hidden rounded-control transition sm:h-44 sm:w-44" <ImageDropzone
style={coverPreview === '' ? 'background:var(--mf-cover-grad)' : undefined}
>
<input
type="file"
name="cover" name="cover"
accept="image/*" icon={ImageIcon}
onchange={onCoverInput} gradient
class="absolute inset-0 cursor-pointer opacity-0" size="hero"
aria-label="Seleccionar portada" onselect={() => (hasCover = true)}
/> />
{#if coverPreview !== ''} {/key}
<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>
</Field> </Field>
<Field label="Título" for="title"> <Field label="Título" for="title">
+3
View File
@@ -203,6 +203,9 @@
oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) var(--tile-hue)) oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) var(--tile-hue))
); );
} }
@utility field-control {
@apply w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none;
}
@layer base { @layer base {
svg.lucide { svg.lucide {