feat(web): favicon + toast feedback (polish)

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) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-12 15:44:24 +02:00
parent c027945191
commit 92d1345503
8 changed files with 76 additions and 2 deletions
+9 -2
View File
@@ -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 (
+38
View File
@@ -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<string>).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 (
<div className="toast-host" role="status" aria-live="polite">
{items.map((t) => (
<div key={t.id} className="toast">
{t.text}
</div>
))}
</div>
);
}
+10
View File
@@ -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; } }
+3
View File
@@ -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();
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#E7DFCC"/>
<circle cx="16" cy="16" r="13" fill="#1B1A17"/>
<circle cx="16" cy="16" r="12" fill="none" stroke="#3a3833" stroke-width="0.5"/>
<circle cx="16" cy="16" r="9.5" fill="none" stroke="#3a3833" stroke-width="0.5"/>
<circle cx="16" cy="16" r="5" fill="#1F5138"/>
<circle cx="16" cy="16" r="1.5" fill="#E7DFCC"/>
</svg>

After

Width:  |  Height:  |  Size: 433 B

+2
View File
@@ -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 })
<ContentsNav />
{children}
</div>
<ToastHost />
</body>
</html>
);
+3
View File
@@ -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();
+3
View File
@@ -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 ?? "Couldnt 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();
}