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 { useEffect, useState } from "react";
|
||||||
import { Modal } from "./modal";
|
import { Modal } from "./modal";
|
||||||
import { CoverArt } from "./cover-art";
|
import { CoverArt } from "./cover-art";
|
||||||
|
import { toast } from "./toast";
|
||||||
|
|
||||||
type Rel = {
|
type Rel = {
|
||||||
rgMbid: string;
|
rgMbid: string;
|
||||||
@@ -61,7 +62,10 @@ export function ArtistModal({
|
|||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ mbid, name }),
|
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) {
|
async function want(r: Rel) {
|
||||||
@@ -78,7 +82,10 @@ export function ArtistModal({
|
|||||||
firstReleaseDate: r.firstReleaseDate,
|
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 (
|
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 .cover { margin-bottom: 7px; }
|
||||||
.am-rel .ac-title { font-size: 0.9rem; }
|
.am-rel .ac-title { font-size: 0.9rem; }
|
||||||
.am-rel-action { margin-top: 7px; }
|
.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 { ArtistModal } from "../_ui/artist-modal";
|
||||||
import { AlbumModal } from "../_ui/album-modal";
|
import { AlbumModal } from "../_ui/album-modal";
|
||||||
import { CoverArt } from "../_ui/cover-art";
|
import { CoverArt } from "../_ui/cover-art";
|
||||||
|
import { toast } from "../_ui/toast";
|
||||||
|
|
||||||
type Suggestion = {
|
type Suggestion = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -86,6 +87,7 @@ export function DiscoverClient() {
|
|||||||
});
|
});
|
||||||
if (res.ok || res.status === 409) {
|
if (res.ok || res.status === 409) {
|
||||||
setFollowedSimilar((s) => new Set(s).add(a.mbid));
|
setFollowedSimilar((s) => new Set(s).add(a.mbid));
|
||||||
|
toast(`Following ${a.name}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +97,7 @@ export function DiscoverClient() {
|
|||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ action }),
|
body: JSON.stringify({ action }),
|
||||||
});
|
});
|
||||||
|
toast(action === "follow" ? "Following" : action === "want" ? "Added to wanted" : "Dismissed");
|
||||||
loadFeed();
|
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 { fraunces } from "./fonts";
|
||||||
import { Masthead } from "./_ui/masthead";
|
import { Masthead } from "./_ui/masthead";
|
||||||
import { ContentsNav } from "./_ui/contents-nav";
|
import { ContentsNav } from "./_ui/contents-nav";
|
||||||
|
import { ToastHost } from "./_ui/toast";
|
||||||
|
|
||||||
export const metadata = { title: "Lyra" };
|
export const metadata = { title: "Lyra" };
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<ContentsNav />
|
<ContentsNav />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
<ToastHost />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { StatTiles } from "./_ui/stat-tiles";
|
|||||||
import { SectionHeader } from "./_ui/section-header";
|
import { SectionHeader } from "./_ui/section-header";
|
||||||
import { JobRow } from "./_ui/job-row";
|
import { JobRow } from "./_ui/job-row";
|
||||||
import { PressedList } from "./_ui/pressed-list";
|
import { PressedList } from "./_ui/pressed-list";
|
||||||
|
import { toast } from "./_ui/toast";
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -53,11 +54,13 @@ export function Queue() {
|
|||||||
async function submit(e: React.FormEvent) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!artist.trim() || !album.trim()) return;
|
if (!artist.trim() || !album.trim()) return;
|
||||||
|
const label = album.trim();
|
||||||
await fetch("/api/requests", {
|
await fetch("/api/requests", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ artist, album }),
|
body: JSON.stringify({ artist, album }),
|
||||||
});
|
});
|
||||||
|
toast(`Queued ${label}`);
|
||||||
setArtist("");
|
setArtist("");
|
||||||
setAlbum("");
|
setAlbum("");
|
||||||
refresh();
|
refresh();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PageHead } from "../_ui/page-head";
|
|||||||
import { SectionHeader } from "../_ui/section-header";
|
import { SectionHeader } from "../_ui/section-header";
|
||||||
import { CoverArt } from "../_ui/cover-art";
|
import { CoverArt } from "../_ui/cover-art";
|
||||||
import { AlbumModal } from "../_ui/album-modal";
|
import { AlbumModal } from "../_ui/album-modal";
|
||||||
|
import { toast } from "../_ui/toast";
|
||||||
|
|
||||||
type Wanted = {
|
type Wanted = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -51,6 +52,7 @@ export function WantedClient() {
|
|||||||
setError((await res.json()).error ?? "Couldn’t add that — no MusicBrainz match?");
|
setError((await res.json()).error ?? "Couldn’t add that — no MusicBrainz match?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
toast("Added to wanted");
|
||||||
setArtist("");
|
setArtist("");
|
||||||
setAlbum("");
|
setAlbum("");
|
||||||
refresh();
|
refresh();
|
||||||
@@ -58,6 +60,7 @@ export function WantedClient() {
|
|||||||
|
|
||||||
async function searchNow(w: Wanted) {
|
async function searchNow(w: Wanted) {
|
||||||
await fetch(`/api/releases/${w.id}/search`, { method: "POST" });
|
await fetch(`/api/releases/${w.id}/search`, { method: "POST" });
|
||||||
|
toast(`Searching for ${w.album}`);
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user