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:
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user