diff --git a/docs/rfcs/RFC-013-chosen-artist-imagery.md b/docs/rfcs/RFC-013-chosen-artist-imagery.md index 8450f614..3b7c529a 100644 --- a/docs/rfcs/RFC-013-chosen-artist-imagery.md +++ b/docs/rfcs/RFC-013-chosen-artist-imagery.md @@ -3,9 +3,12 @@ - **Status**: Proposed - **Implemented by**: in progress, in the order of "The order of the work" below: [#308](https://github.com/InstaZDLL/waveflow-server/pull/308) (this - RFC) and [#309](https://github.com/InstaZDLL/waveflow-server/pull/309) + RFC), [#309](https://github.com/InstaZDLL/waveflow-server/pull/309) (serving the choice: `artist_image_choice`, the effective portrait, - `banner_hash`, the upload and removal routes). The + `banner_hash`, the upload and removal routes) and + [#310](https://github.com/InstaZDLL/waveflow-server/pull/310) (drawing it in + the web client: the banner behind the artist header, and "Change + portrait" / "Change banner" for owners and managers). The *Status* field above never flips in this project; this line is the one that says what is running. - **Date**: 2026-10-23 diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index 459dc406..3b111d7d 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -5331,3 +5331,133 @@ test("closes a folder's form when another folder is opened", async ({ page }) => await expect(page.getByLabel("Folder name")).toHaveCount(0); expect(state.writes).toEqual([]); }); + +/** + * A stand-in for the artist routes of RFC-013 with state: the page re-reads + * after every write, and what it shows must be what the write did. + */ +async function serveArtistImages(page: Page) { + const state = { + portrait: null as string | null, + banner: null as string | null, + writes: [] as Array<{ method: string; kind: string; bytes: number }>, + refuseNext: false, + }; + const detail = () => ({ + ...artistDetail, + library_id: "library-1", + artwork_hash: state.portrait ?? "f".repeat(64), + ...(state.banner ? { banner_hash: state.banner } : {}), + ...(state.portrait || state.banner + ? { + image_choice: { + ...(state.portrait ? { portrait: { source: "upload", set_at: 1 } } : {}), + ...(state.banner ? { banner: { source: "upload", set_at: 1 } } : {}), + }, + } + : {}), + }); + await page.route("**/api/v2/artists/artist-1", (route) => + route.fulfill({ json: detail() }), + ); + await page.route("**/api/v2/artists/artist-1/image/*", async (route) => { + const request = route.request(); + const kind = new URL(request.url()).pathname.split("/").at(-1) ?? ""; + const bytes = request.postDataBuffer()?.length ?? 0; + state.writes.push({ method: request.method(), kind, bytes }); + if (request.method() === "DELETE") { + if (kind === "portrait") state.portrait = null; + else state.banner = null; + await route.fulfill({ status: 204, body: "" }); + return; + } + if (state.refuseNext) { + state.refuseNext = false; + await route.fulfill({ status: 422, json: { error: "validation" } }); + return; + } + const hash = (kind === "portrait" ? "b" : "c").repeat(64); + if (kind === "portrait") state.portrait = hash; + else state.banner = hash; + await route.fulfill({ json: detail() }); + }); + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ); + await page.route("**/api/v2/artwork/*", (route) => + route.fulfill({ contentType: "image/png", body: png }), + ); + return { state, png }; +} + +/** + * Choosing an artist's banner and portrait by hand (RFC-013 step 3): the file + * goes as its own bytes, the header draws the banner behind it, a chosen + * portrait says so, and each can go back to automatic. + */ +test("changes an artist's banner and portrait from the artist page", async ({ + page, +}) => { + const { state, png } = await serveArtistImages(page); + await page.goto("/artists/artist-1"); + const hero = page.locator(".artist-hero"); + await expect(hero.getByRole("button", { name: "Change portrait" })).toBeVisible(); + await expect(hero.getByRole("button", { name: "Change banner" })).toBeVisible(); + await expect(hero.getByRole("button", { name: "No banner" })).toHaveCount(0); + // With no banner, the portrait is the backdrop, blurred. + await expect(hero.locator(".artist-hero-ambient")).toBeAttached(); + + await hero.getByLabel("Change banner").setInputFiles({ + name: "banner.png", + mimeType: "image/png", + buffer: png, + }); + await expect(hero.getByRole("status")).toHaveText("Banner changed."); + await expect(hero.locator("img.artist-banner")).toBeVisible(); + await expect(hero.locator(".artist-hero-ambient")).toHaveCount(0); + expect(state.writes).toEqual([{ method: "PUT", kind: "banner", bytes: png.length }]); + await expectNoWcagViolations(page); + + await hero.getByLabel("Change portrait").setInputFiles({ + name: "portrait.png", + mimeType: "image/png", + buffer: png, + }); + await expect(hero.getByRole("status")).toHaveText("Portrait changed."); + await expect(hero.getByText("Portrait chosen by hand")).toBeVisible(); + + // A file the server refuses is said, and nothing on screen changes. + state.refuseNext = true; + await hero.getByLabel("Change portrait").setInputFiles({ + name: "notes.png", + mimeType: "image/png", + buffer: Buffer.from("not an image"), + }); + await expect(hero.getByRole("alert")).toHaveText("Choose a JPEG, PNG or WebP image."); + + // Back to automatic: no banner, and the fetched portrait again. + await hero.getByRole("button", { name: "No banner" }).click(); + await expect(hero.getByRole("status")).toHaveText("Banner removed."); + await expect(hero.locator("img.artist-banner")).toHaveCount(0); + await hero.getByRole("button", { name: "Automatic portrait" }).click(); + await expect(hero.getByRole("status")).toHaveText("Back to the automatic portrait."); + await expect(hero.getByText("Portrait chosen by hand")).toHaveCount(0); + expect(state.writes.slice(-2)).toEqual([ + { method: "DELETE", kind: "banner", bytes: 0 }, + { method: "DELETE", kind: "portrait", bytes: 0 }, + ]); +}); + +/** A listener sees the chosen banner and is offered nothing to change. */ +test("shows a listener the artist's banner with nothing to change", async ({ + page, +}) => { + libraries = [{ ...library("library-1", "Ma musique"), role: "listener" }]; + const { state } = await serveArtistImages(page); + state.banner = "c".repeat(64); + await page.goto("/artists/artist-1"); + const hero = page.locator(".artist-hero"); + await expect(hero.locator("img.artist-banner")).toBeVisible(); + await expect(hero.getByRole("button", { name: /Change/ })).toHaveCount(0); +}); diff --git a/webapp/src/api/catalog.ts b/webapp/src/api/catalog.ts index 73bb4c84..d085b387 100644 --- a/webapp/src/api/catalog.ts +++ b/webapp/src/api/catalog.ts @@ -1,4 +1,4 @@ -import { call, collect, scoped } from "./client"; +import { call, collect, request, scoped } from "./client"; export type Album = { id: string; @@ -84,10 +84,25 @@ export type ArtistEnrichment = { similar: { artists: Artist[]; source: EnrichmentSource } | null; }; +/** Which of an artist's two hand-chosen images (RFC-013). */ +export type ArtistImageKind = "portrait" | "banner"; + +/** Where a chosen image came from, and when it was chosen. */ +export type ImageChoice = { + source: "upload" | "deezer"; + set_at: number; +}; + export type ArtistDetail = Artist & { + /** The library the artist belongs to: whose owners may choose its images. */ + library_id: string; albums: Album[]; /** Always present on the native page; optional so an older server parses. */ enrichment?: ArtistEnrichment; + /** The banner chosen by hand, absent when none was. */ + banner_hash?: string; + /** What was chosen by hand, kind by kind; absent when nothing was. */ + image_choice?: { portrait?: ImageChoice; banner?: ImageChoice }; }; export type SearchResult = { @@ -189,6 +204,31 @@ export const listArtists = (libraryId?: string) => export const getArtist = (id: string) => call(`/api/v2/artists/${id}`); +/** + * Chooses an artist's portrait or banner from an image file (RFC-013): sent as + * its own bytes, recognised by the server from them. Answers the artist page. + */ +export const setArtistImage = ( + artistId: string, + kind: ArtistImageKind, + file: Blob, +) => + call(`/api/v2/artists/${artistId}/image/${kind}`, { + method: "PUT", + body: file, + headers: { "content-type": "application/octet-stream" }, + }); + +/** Goes back to the automatic image: the fetched portrait, or no banner. */ +export const removeArtistImage = async ( + artistId: string, + kind: ArtistImageKind, +): Promise => { + await request(`/api/v2/artists/${artistId}/image/${kind}`, { + method: "DELETE", + }); +}; + /** * The albums credited to an artist, without the artist's page. * diff --git a/webapp/src/api/index.ts b/webapp/src/api/index.ts index 5d391f51..9e33f9cb 100644 --- a/webapp/src/api/index.ts +++ b/webapp/src/api/index.ts @@ -26,6 +26,7 @@ export type { Artist, ArtistDetail, ArtistEnrichment, + ArtistImageKind, EnrichmentSource, Genre, SearchResult, @@ -45,8 +46,10 @@ export { listGenres, listRandomSongs, listSongs, + removeArtistImage, SONG_PAGE, search, + setArtistImage, } from "./catalog"; export type { SessionUser, WebSession } from "./client"; export { diff --git a/webapp/src/api/queries.test.ts b/webapp/src/api/queries.test.ts index 4b226b13..7d5fd2d5 100644 --- a/webapp/src/api/queries.test.ts +++ b/webapp/src/api/queries.test.ts @@ -9,6 +9,7 @@ import { const artist = (state?: ArtistEnrichment["state"]): ArtistDetail => ({ id: "artist-1", + library_id: "library-1", name: "Björk", album_count: 0, artwork_hash: null, diff --git a/webapp/src/components/artist-image-controls.tsx b/webapp/src/components/artist-image-controls.tsx new file mode 100644 index 00000000..850e4606 --- /dev/null +++ b/webapp/src/components/artist-image-controls.tsx @@ -0,0 +1,158 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useRef, useState } from "react"; + +import { + ApiError, + type ArtistDetail, + type ArtistImageKind, + removeArtistImage, + setArtistImage, +} from "../api"; +import { artistQuery } from "../api/queries"; +import { type TranslationKey, useI18n } from "../state/i18n"; +import { Icon } from "./icons"; + +/** What the server takes: recognised from the bytes, these three only. */ +const ACCEPT = "image/jpeg,image/png,image/webp"; + +const LABELS: Record< + ArtistImageKind, + { + change: TranslationKey; + reset: TranslationKey; + changed: TranslationKey; + reverted: TranslationKey; + } +> = { + portrait: { + change: "artistImage.changePortrait", + reset: "artistImage.resetPortrait", + changed: "artistImage.portraitChanged", + reverted: "artistImage.portraitReverted", + }, + banner: { + change: "artistImage.changeBanner", + reset: "artistImage.resetBanner", + changed: "artistImage.bannerChanged", + reverted: "artistImage.bannerReverted", + }, +}; + +/** + * Change an artist's portrait or banner, or go back to the automatic one + * (RFC-013). Drawn only for the library's owners and managers; the server + * refuses anyone else regardless. + * + * A file is sent as it is: no crop, no resize. The banner is framed by the + * page at a fixed position, as the desktop frames it. + */ +export function ArtistImageControls({ artist }: { artist: ArtistDetail }) { + const { t } = useI18n(); + const client = useQueryClient(); + const pickers = { + portrait: useRef(null), + banner: useRef(null), + }; + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState<{ error: boolean; text: string } | null>( + null, + ); + + async function settle(next?: ArtistDetail) { + // The answer to the write is the page as it now reads; holding it first + // keeps the old picture off the screen while everything else re-reads. + if (next) client.setQueryData(artistQuery(artist.id).queryKey, next); + await Promise.all([ + client.invalidateQueries({ queryKey: ["artist", artist.id] }), + client.invalidateQueries({ queryKey: ["artists"] }), + ]); + } + + async function choose(kind: ArtistImageKind, file: File) { + setBusy(true); + setNotice(null); + try { + await settle(await setArtistImage(artist.id, kind, file)); + setNotice({ error: false, text: t(LABELS[kind].changed) }); + } catch (error) { + setNotice({ + error: true, + text: t( + error instanceof ApiError && error.status === 413 + ? "artistImage.tooLarge" + : error instanceof ApiError && error.status === 422 + ? "artistImage.notAnImage" + : "artistImage.failed", + ), + }); + } finally { + setBusy(false); + } + } + + async function reset(kind: ArtistImageKind) { + setBusy(true); + setNotice(null); + try { + await removeArtistImage(artist.id, kind); + await settle(); + setNotice({ error: false, text: t(LABELS[kind].reverted) }); + } catch { + setNotice({ error: true, text: t("artistImage.failed") }); + } finally { + setBusy(false); + } + } + + const kinds: ArtistImageKind[] = ["portrait", "banner"]; + return ( +
+
+ {kinds.map((kind) => ( + + + {artist.image_choice?.[kind] ? ( + + ) : null} + { + const file = event.target.files?.[0]; + // Cleared, so the same file chosen again is a change too. + event.target.value = ""; + if (file) void choose(kind, file); + }} + /> + + ))} +
+ {notice ? ( +

+ {notice.text} +

+ ) : null} +
+ ); +} diff --git a/webapp/src/pages/artist-page.tsx b/webapp/src/pages/artist-page.tsx index 562bde2a..c7483f06 100644 --- a/webapp/src/pages/artist-page.tsx +++ b/webapp/src/pages/artist-page.tsx @@ -8,10 +8,12 @@ import { enrichmentRetryDelay, } from "../api/queries"; import { AlbumGrid } from "../components/album-grid"; +import { ArtistImageControls } from "../components/artist-image-controls"; import { Artwork } from "../components/artwork"; import { Loading } from "../components/page-shell"; import { SongTable } from "../components/song-table"; import { useI18n } from "../state/i18n"; +import { mayCorrectTracks, useLibraryScope } from "../state/library-scope"; /** A provider as a reader knows it, rather than as the setting spells it. */ const PROVIDER_NAMES: Record = { @@ -96,6 +98,7 @@ function ArtistEnrichmentSection({ export function ArtistPage({ artistId }: { artistId: string }) { const { t, locale } = useI18n(); const client = useQueryClient(); + const { libraries } = useLibraryScope(); const query = artistQuery(artistId); const { data: value, error } = useQuery(query); /** @@ -112,6 +115,14 @@ export function ArtistPage({ artistId }: { artistId: string }) { // The artist row carries the portrait once the server has linked it; the // enrichment names it as soon as it is cached, which may be a visit sooner. const portrait = value.artwork_hash ?? image?.artwork_hash ?? null; + // The banner chosen by hand (RFC-013), or else the portrait, blurred, as the + // desktop draws it: a header that carries the artist even with no banner. + const banner = value.banner_hash ?? null; + const backdrop = banner ?? portrait; + // Offered where the server would take it: owners and managers. + const mayChoose = mayCorrectTracks( + libraries.find((library) => library.id === value.library_id), + ); // Counted exactly as `artistQuery` counts, so the note goes when the asking // does. const asked = client.getQueryState(query.queryKey); @@ -122,7 +133,16 @@ export function ArtistPage({ artistId }: { artistId: string }) { ) !== false; return (
-
+
+ {backdrop ? ( + + ) : null} {t("artists.libraryCount", { count: value.album_count })}

- {image && image.artwork_hash === portrait ? ( + {value.image_choice?.portrait ? ( +

{t("artistImage.chosen")}

+ ) : image && image.artwork_hash === portrait ? (

{t("artists.portraitSource", { provider: @@ -147,6 +169,7 @@ export function ArtistPage({ artistId }: { artistId: string }) {

) : null} + {mayChoose ? : null}
{/* Shown only once there is a history to show. An artist nobody has diff --git a/webapp/src/state/i18n.tsx b/webapp/src/state/i18n.tsx index 9f8bcd21..5d9efa97 100644 --- a/webapp/src/state/i18n.tsx +++ b/webapp/src/state/i18n.tsx @@ -443,6 +443,18 @@ const en = { "nav.upload": "Upload", "nav.folders": "Folders", "common.back": "Back", + "artistImage.changePortrait": "Change portrait", + "artistImage.changeBanner": "Change banner", + "artistImage.resetPortrait": "Automatic portrait", + "artistImage.resetBanner": "No banner", + "artistImage.portraitChanged": "Portrait changed.", + "artistImage.bannerChanged": "Banner changed.", + "artistImage.portraitReverted": "Back to the automatic portrait.", + "artistImage.bannerReverted": "Banner removed.", + "artistImage.tooLarge": "This image is larger than 10 MB.", + "artistImage.notAnImage": "Choose a JPEG, PNG or WebP image.", + "artistImage.failed": "That did not work. Try again.", + "artistImage.chosen": "Portrait chosen by hand", "folders.count": { one: "{count} folder", other: "{count} folders" }, "folders.where": "Folder path", "folders.subfolders": "Folders", @@ -1132,6 +1144,18 @@ const fr: Record = { "nav.upload": "Téléverser", "nav.folders": "Dossiers", "common.back": "Retour", + "artistImage.changePortrait": "Changer le portrait", + "artistImage.changeBanner": "Changer la bannière", + "artistImage.resetPortrait": "Portrait automatique", + "artistImage.resetBanner": "Sans bannière", + "artistImage.portraitChanged": "Portrait changé.", + "artistImage.bannerChanged": "Bannière changée.", + "artistImage.portraitReverted": "Retour au portrait automatique.", + "artistImage.bannerReverted": "Bannière retirée.", + "artistImage.tooLarge": "Cette image dépasse 10 Mo.", + "artistImage.notAnImage": "Choisissez une image JPEG, PNG ou WebP.", + "artistImage.failed": "Cela n’a pas fonctionné. Réessayez.", + "artistImage.chosen": "Portrait choisi à la main", "folders.count": { one: "{count} dossier", other: "{count} dossiers" }, "folders.where": "Chemin du dossier", "folders.subfolders": "Dossiers", diff --git a/webapp/src/styles/styles.css b/webapp/src/styles/styles.css index db59882c..f764078a 100644 --- a/webapp/src/styles/styles.css +++ b/webapp/src/styles/styles.css @@ -3066,3 +3066,135 @@ button.danger { font-size: var(--text-xs); vertical-align: middle; } + +/* The artist header's backdrop (RFC-013): the banner chosen by hand, framed at + a fixed point as the desktop frames it, or else the portrait, blurred. The + gradient keeps the title legible over any picture, so the text goes light + whatever the theme. */ +.artist-hero { + position: relative; + isolation: isolate; + overflow: hidden; +} + +.artist-hero.has-backdrop { + color: #fff; +} + +.artist-hero.has-backdrop .muted, +.artist-hero.has-backdrop .eyebrow { + color: rgb(255 255 255 / 76%); +} + +.artist-hero.has-banner { + min-height: clamp(18rem, 32vw, 27rem); +} + +.artist-banner.cover { + position: absolute; + z-index: -2; + inset: 0; + width: 100%; + height: 100%; + max-width: none; + border: 0; + border-radius: 0; + object-fit: cover; + object-position: center 30%; + pointer-events: none; +} + +.artist-hero-ambient.cover { + position: absolute; + z-index: -2; + inset: -35%; + width: 170%; + height: 170%; + max-width: none; + border: 0; + border-radius: 0; + object-fit: cover; + filter: blur(5rem) saturate(1.3); + opacity: 0.6; + pointer-events: none; +} + +.artist-hero.has-backdrop::before { + position: absolute; + z-index: -1; + inset: 0; + background: linear-gradient( + 180deg, + rgb(11 11 14 / 15%) 0%, + rgb(11 11 14 / 50%) 55%, + rgb(11 11 14 / 82%) 100% + ); + content: ""; +} + +/* Out of the flow on a wide screen, in the header's top corner, so the + controls never narrow the title beside the portrait. */ +.artist-image-controls { + position: absolute; + top: var(--space-6); + right: clamp(var(--space-6), 4vw, var(--space-12)); + display: grid; + justify-items: end; + gap: var(--space-2); + max-width: 50%; +} + +.artist-image-buttons { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); +} + +.artist-image-pair { + display: inline-flex; + gap: var(--space-1); +} + +.artist-image-button { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-height: 2.25rem; + padding: var(--space-1) var(--space-3); + border-radius: 999px; + font-size: var(--text-sm); +} + +.artist-hero.has-backdrop .artist-image-button { + border: 1px solid rgb(255 255 255 / 22%); + background: rgb(0 0 0 / 38%); + color: #fff; + backdrop-filter: blur(6px); +} + +.artist-hero.has-backdrop .artist-image-button.subtle { + background: transparent; +} + +.artist-image-notice { + margin: 0; + font-size: var(--text-sm); +} + +@media (max-width: 820px) { + .artist-hero { + flex-wrap: wrap; + } + + .artist-image-controls { + position: static; + width: 100%; + max-width: none; + justify-items: start; + } + + .artist-image-buttons { + justify-content: flex-start; + } +}