Make album cover pictures mandatory, unify cover handling with playlists
Albums previously had no cover concept at all: no picture upload
flow, no GET /albums/{id}/cover, and AlbumApplicationResponse's
image-key fields were plumbed but never populated. Description,
Pictures and TrackCount already lived on the shared Album base entity
for both local and external albums, so unifying the cover story only
required building the missing local-album picture pipeline.
Backend, cloning PlayList's existing async picture-processing
architecture exactly (MassTransit routing slip + saga), but making
the picture mandatory instead of optional (matching Track's pattern):
- AlbumPictures moves from all-nullable to required fields.
- New AlbumConfiguration (routes/sizes), deliberately without
PlayList's preset-picture fallback since a picture is now required
up front.
- CreateAlbum now takes a mandatory PictureIntentId, validates and
consumes the upload intent, and kicks off the same
copy-to-final -> resize -> upload -> update-entity routing slip
PlayLists use, via a new AlbumProcessingState saga that marks the
album Failed on a picture-processing failure.
- UpdateAlbum takes an optional NewPictureIntentId to replace the
cover later.
- New GET /albums/{id}/cover and POST /albums/upload-picture
endpoints, mirroring the playlist ones.
- New EF migration for the AlbumProcessingState saga table.
Frontend:
- AlbumForm gains a cover dropzone, required on create and optional
on edit (via ImageDropzone's new `required` prop), uploaded through
the same presigned-upload helper playlists use.
- New /api/albums/{id}/cover proxy route.
- PlaylistArt gains a generic coverUrl prop so AlbumHeader and the
album list/detail cards render the real uploaded cover instead of
only the track mosaic fallback.
Delete-time picture cleanup for albums is intentionally left as a
follow-up, matching the existing gap in playlist deletion.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import ImageDropzone from './ImageDropzone.svelte';
|
||||
import Button from './Button.svelte';
|
||||
import Field from './Field.svelte';
|
||||
import Input from './Input.svelte';
|
||||
@@ -14,6 +15,8 @@
|
||||
initialDescription?: string;
|
||||
initialReleaseYear?: number;
|
||||
titlePlaceholder?: string;
|
||||
coverFallbackUrl?: string;
|
||||
requireCover?: boolean;
|
||||
formMessage?: string;
|
||||
submitLabel: string;
|
||||
submittingLabel: string;
|
||||
@@ -27,6 +30,8 @@
|
||||
initialDescription = '',
|
||||
initialReleaseYear,
|
||||
titlePlaceholder = '',
|
||||
coverFallbackUrl,
|
||||
requireCover = false,
|
||||
formMessage,
|
||||
submitLabel,
|
||||
submittingLabel,
|
||||
@@ -45,6 +50,7 @@
|
||||
<form
|
||||
method="POST"
|
||||
{action}
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update, result }) => {
|
||||
@@ -55,28 +61,36 @@
|
||||
}}
|
||||
class="mt-4 w-full max-w-2xl space-y-4"
|
||||
>
|
||||
<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-col items-stretch gap-5 sm:flex-row sm:gap-8">
|
||||
<Field label="Portada" optional={!requireCover}>
|
||||
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} required={requireCover} />
|
||||
</Field>
|
||||
|
||||
<Field label="Año de publicación" for="album-year" optional>
|
||||
<Input
|
||||
id="album-year"
|
||||
name="releaseYear"
|
||||
type="number"
|
||||
min={ALBUM_EARLIEST_YEAR}
|
||||
max={latestYear}
|
||||
bind:value={releaseYear}
|
||||
placeholder={String(new Date().getFullYear())}
|
||||
/>
|
||||
</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" optional>
|
||||
<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" optional>
|
||||
<Textarea
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
interface Props {
|
||||
name: string;
|
||||
fallbackUrl?: string;
|
||||
required?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
name,
|
||||
fallbackUrl,
|
||||
required = false,
|
||||
class:
|
||||
klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control border border-line bg-surface hover:border-accent/50'
|
||||
}: Props = $props();
|
||||
@@ -30,6 +32,7 @@
|
||||
type="file"
|
||||
{name}
|
||||
accept="image/*"
|
||||
{required}
|
||||
onchange={onInput}
|
||||
class="absolute inset-0 cursor-pointer opacity-0"
|
||||
aria-label="Seleccionar portada"
|
||||
|
||||
@@ -3,26 +3,39 @@
|
||||
|
||||
interface Props {
|
||||
playlistId?: string;
|
||||
coverUrl?: string;
|
||||
trackIds: (string | number)[];
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
version?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { playlistId, trackIds, size = 'medium', version, class: klass = '' }: Props = $props();
|
||||
let {
|
||||
playlistId,
|
||||
coverUrl,
|
||||
trackIds,
|
||||
size = 'medium',
|
||||
version,
|
||||
class: klass = ''
|
||||
}: Props = $props();
|
||||
|
||||
let coverFailed = $state(false);
|
||||
|
||||
const mosaic = $derived(trackIds.length >= 4);
|
||||
const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null);
|
||||
const src = $derived(
|
||||
coverUrl
|
||||
? coverUrl
|
||||
: playlistId
|
||||
? `/api/playlists/${playlistId}/cover?size=${size}${version ? `&v=${encodeURIComponent(version)}` : ''}`
|
||||
: undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if playlistId && !coverFailed}
|
||||
{#if src && !coverFailed}
|
||||
<div class="relative overflow-hidden bg-surface {klass}">
|
||||
<img
|
||||
src="/api/playlists/{playlistId}/cover?size={size}{version
|
||||
? `&v=${encodeURIComponent(version)}`
|
||||
: ''}"
|
||||
{src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onerror={() => (coverFailed = true)}
|
||||
|
||||
Reference in New Issue
Block a user