From 92d1345503c939ac5b2b6544bfcad5813fcab95b Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 12 Jul 2026 15:44:24 +0200 Subject: [PATCH] feat(web): favicon + toast feedback (polish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a vinyl-record favicon (icon.svg) — fixes the favicon.ico 404 console error. Lightweight toast system (ToastHost + toast()) fired on request, wanted add, search, follow, and want actions so submits give explicit feedback. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/app/_ui/artist-modal.tsx | 11 +++++-- web/src/app/_ui/toast.tsx | 38 ++++++++++++++++++++++++ web/src/app/design.css | 10 +++++++ web/src/app/discover/discover-client.tsx | 3 ++ web/src/app/icon.svg | 8 +++++ web/src/app/layout.tsx | 2 ++ web/src/app/queue.tsx | 3 ++ web/src/app/wanted/wanted-client.tsx | 3 ++ 8 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 web/src/app/_ui/toast.tsx create mode 100644 web/src/app/icon.svg diff --git a/web/src/app/_ui/artist-modal.tsx b/web/src/app/_ui/artist-modal.tsx index 74ba3ea..2cefece 100644 --- a/web/src/app/_ui/artist-modal.tsx +++ b/web/src/app/_ui/artist-modal.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { Modal } from "./modal"; import { CoverArt } from "./cover-art"; +import { toast } from "./toast"; type Rel = { rgMbid: string; @@ -61,7 +62,10 @@ export function ArtistModal({ headers: { "content-type": "application/json" }, body: JSON.stringify({ mbid, name }), }); - if (res.ok || res.status === 409) setFollowed(true); + if (res.ok || res.status === 409) { + setFollowed(true); + toast(`Following ${name}`); + } } async function want(r: Rel) { @@ -78,7 +82,10 @@ export function ArtistModal({ firstReleaseDate: r.firstReleaseDate, }), }); - if (res.ok) setWanted((s) => new Set(s).add(r.rgMbid)); + if (res.ok) { + setWanted((s) => new Set(s).add(r.rgMbid)); + toast(`Added ${r.album}`); + } } return ( diff --git a/web/src/app/_ui/toast.tsx b/web/src/app/_ui/toast.tsx new file mode 100644 index 0000000..adf0c0b --- /dev/null +++ b/web/src/app/_ui/toast.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +/** Fire a transient toast from anywhere: `toast("Queued")`. */ +export function toast(message: string) { + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("lyra-toast", { detail: message })); + } +} + +/** Renders toasts fired via `toast()`. Mounted once in the root layout. */ +export function ToastHost() { + const [items, setItems] = useState<{ id: number; text: string }[]>([]); + const nextId = useRef(0); + + useEffect(() => { + function onToast(e: Event) { + const text = (e as CustomEvent).detail; + const id = ++nextId.current; + setItems((xs) => [...xs, { id, text }]); + setTimeout(() => setItems((xs) => xs.filter((x) => x.id !== id)), 2600); + } + window.addEventListener("lyra-toast", onToast); + return () => window.removeEventListener("lyra-toast", onToast); + }, []); + + if (items.length === 0) return null; + return ( +
+ {items.map((t) => ( +
+ {t.text} +
+ ))} +
+ ); +} diff --git a/web/src/app/design.css b/web/src/app/design.css index f70041c..fa4df01 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -332,3 +332,13 @@ nav.contents .sep { flex: 1; } .am-rel .cover { margin-bottom: 7px; } .am-rel .ac-title { font-size: 0.9rem; } .am-rel-action { margin-top: 7px; } + +/* ── Toasts ───────────────────────────────────────────── */ +.toast-host { position: fixed; bottom: 26px; left: 50%; transform: translateX(-50%); z-index: 200; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; } +.toast { + background: var(--ink); color: var(--paper); font-family: var(--mono); font-size: 0.7rem; + letter-spacing: 0.1em; text-transform: uppercase; padding: 11px 18px; + box-shadow: 0 14px 40px -12px rgba(0, 0, 0, 0.6); animation: toast-in 0.18s ease-out; +} +@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } +@media (prefers-reduced-motion: reduce) { .toast { animation: none; } } diff --git a/web/src/app/discover/discover-client.tsx b/web/src/app/discover/discover-client.tsx index 40af92a..9085a49 100644 --- a/web/src/app/discover/discover-client.tsx +++ b/web/src/app/discover/discover-client.tsx @@ -6,6 +6,7 @@ import { SectionHeader } from "../_ui/section-header"; import { ArtistModal } from "../_ui/artist-modal"; import { AlbumModal } from "../_ui/album-modal"; import { CoverArt } from "../_ui/cover-art"; +import { toast } from "../_ui/toast"; type Suggestion = { id: string; @@ -86,6 +87,7 @@ export function DiscoverClient() { }); if (res.ok || res.status === 409) { setFollowedSimilar((s) => new Set(s).add(a.mbid)); + toast(`Following ${a.name}`); } } @@ -95,6 +97,7 @@ export function DiscoverClient() { headers: { "content-type": "application/json" }, body: JSON.stringify({ action }), }); + toast(action === "follow" ? "Following" : action === "want" ? "Added to wanted" : "Dismissed"); loadFeed(); } diff --git a/web/src/app/icon.svg b/web/src/app/icon.svg new file mode 100644 index 0000000..9515599 --- /dev/null +++ b/web/src/app/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 7e0113d..f5e58b1 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -3,6 +3,7 @@ import "./design.css"; import { fraunces } from "./fonts"; import { Masthead } from "./_ui/masthead"; import { ContentsNav } from "./_ui/contents-nav"; +import { ToastHost } from "./_ui/toast"; export const metadata = { title: "Lyra" }; @@ -15,6 +16,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} + ); diff --git a/web/src/app/queue.tsx b/web/src/app/queue.tsx index af4dec4..865141f 100644 --- a/web/src/app/queue.tsx +++ b/web/src/app/queue.tsx @@ -6,6 +6,7 @@ import { StatTiles } from "./_ui/stat-tiles"; import { SectionHeader } from "./_ui/section-header"; import { JobRow } from "./_ui/job-row"; import { PressedList } from "./_ui/pressed-list"; +import { toast } from "./_ui/toast"; type Row = { id: string; @@ -53,11 +54,13 @@ export function Queue() { async function submit(e: React.FormEvent) { e.preventDefault(); if (!artist.trim() || !album.trim()) return; + const label = album.trim(); await fetch("/api/requests", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ artist, album }), }); + toast(`Queued ${label}`); setArtist(""); setAlbum(""); refresh(); diff --git a/web/src/app/wanted/wanted-client.tsx b/web/src/app/wanted/wanted-client.tsx index 619acb5..524ea28 100644 --- a/web/src/app/wanted/wanted-client.tsx +++ b/web/src/app/wanted/wanted-client.tsx @@ -5,6 +5,7 @@ import { PageHead } from "../_ui/page-head"; import { SectionHeader } from "../_ui/section-header"; import { CoverArt } from "../_ui/cover-art"; import { AlbumModal } from "../_ui/album-modal"; +import { toast } from "../_ui/toast"; type Wanted = { id: string; @@ -51,6 +52,7 @@ export function WantedClient() { setError((await res.json()).error ?? "Couldn’t add that — no MusicBrainz match?"); return; } + toast("Added to wanted"); setArtist(""); setAlbum(""); refresh(); @@ -58,6 +60,7 @@ export function WantedClient() { async function searchNow(w: Wanted) { await fetch(`/api/releases/${w.id}/search`, { method: "POST" }); + toast(`Searching for ${w.album}`); refresh(); }