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:
@@ -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,38 +40,23 @@
|
|||||||
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>
|
|
||||||
<ImageDropzone
|
|
||||||
name="cover"
|
|
||||||
fallbackUrl={coverFallbackUrl}
|
|
||||||
required={requireCover}
|
|
||||||
icon={Disc3}
|
|
||||||
gradient
|
|
||||||
class="h-36 w-36 rounded-2xl"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-4">
|
|
||||||
<Field label="Título" for="album-title">
|
<Field label="Título" for="album-title">
|
||||||
<Input
|
<Input
|
||||||
id="album-title"
|
id="album-title"
|
||||||
@@ -97,9 +79,8 @@
|
|||||||
placeholder={String(new Date().getFullYear())}
|
placeholder={String(new Date().getFullYear())}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
{/snippet}
|
||||||
</div>
|
{#snippet extra()}
|
||||||
|
|
||||||
<Field label="Descripción" for="album-description">
|
<Field label="Descripción" for="album-description">
|
||||||
<Textarea
|
<Textarea
|
||||||
id="album-description"
|
id="album-description"
|
||||||
@@ -109,15 +90,5 @@
|
|||||||
placeholder="¿De qué va este álbum?"
|
placeholder="¿De qué va este álbum?"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
{/snippet}
|
||||||
{#if formMessage}
|
</CoverForm>
|
||||||
<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,37 +42,23 @@
|
|||||||
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
|
{#snippet fields()}
|
||||||
<input type="hidden" name="visibility" value={visibility} />
|
<input type="hidden" name="visibility" value={visibility} />
|
||||||
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
|
|
||||||
<Field>
|
|
||||||
<ImageDropzone
|
|
||||||
name="cover"
|
|
||||||
fallbackUrl={coverFallbackUrl}
|
|
||||||
icon={Music}
|
|
||||||
gradient
|
|
||||||
class="h-36 w-36 rounded-2xl"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-2">
|
|
||||||
<Field label="Nombre" for="playlist-name">
|
<Field label="Nombre" for="playlist-name">
|
||||||
{#snippet hint()}
|
{#snippet hint()}
|
||||||
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
|
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
|
||||||
@@ -99,9 +83,8 @@
|
|||||||
class="flex-1"
|
class="flex-1"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
{/snippet}
|
||||||
</div>
|
{#snippet extra()}
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4 rounded-art bg-surface px-4 py-3.5">
|
<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="min-w-0">
|
||||||
<div class="text-sm font-medium text-fg-2">Visibilidad</div>
|
<div class="text-sm font-medium text-fg-2">Visibilidad</div>
|
||||||
@@ -111,51 +94,13 @@
|
|||||||
: 'Cualquiera con el enlace puede verla'}
|
: 'Cualquiera con el enlace puede verla'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1">
|
<SegmentedControl
|
||||||
<div
|
bind:value={visibility}
|
||||||
class="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full bg-fg transition-transform duration-300 ease-out {visibility ===
|
options={[
|
||||||
'public'
|
{ value: 'private', label: 'Privada' },
|
||||||
? 'translate-x-full'
|
{ value: 'public', label: 'Pública' }
|
||||||
: ''}"
|
]}
|
||||||
></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>
|
||||||
</div>
|
{/snippet}
|
||||||
|
</CoverForm>
|
||||||
{#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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user