From 95a0daabe34b3eff2d8d97df75fef95422d460dc Mon Sep 17 00:00:00 2001 From: gitmotion <43588713+gitmotion@users.noreply.github.com> Date: Thu, 21 Aug 2025 22:16:39 -0700 Subject: [PATCH] feat(toaster): Implement Toaster and remove inline save messages - #300, #227 Remove save messages --- .../src/components/config/DownloadsTab.tsx | 28 ++----- .../src/components/config/FormattingTab.tsx | 14 +--- .../src/components/config/GeneralTab.tsx | 35 ++++---- .../src/components/config/ServerTab.tsx | 26 +----- .../src/components/config/WatchTab.tsx | 60 +++++++------- spotizerr-ui/src/components/ui/Toaster.tsx | 47 +++++++++++ spotizerr-ui/src/lib/theme.ts | 83 ++++++++++--------- spotizerr-ui/src/main.tsx | 2 + 8 files changed, 155 insertions(+), 140 deletions(-) create mode 100644 spotizerr-ui/src/components/ui/Toaster.tsx diff --git a/spotizerr-ui/src/components/config/DownloadsTab.tsx b/spotizerr-ui/src/components/config/DownloadsTab.tsx index 81eec47..ffabfd0 100644 --- a/spotizerr-ui/src/components/config/DownloadsTab.tsx +++ b/spotizerr-ui/src/components/config/DownloadsTab.tsx @@ -53,7 +53,7 @@ const CONVERSION_FORMATS: Record = { // --- API Functions --- const saveDownloadConfig = async (data: Partial) => { - const payload: any = { ...data }; + const payload: Partial = { ...data }; const { data: response } = await authApiClient.client.post("/config", payload); return response; }; @@ -72,7 +72,6 @@ const fetchCredentials = async (service: "spotify" | "deezer"): Promise(""); - const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle"); // Fetch watch config const { data: watchConfig } = useQuery({ @@ -89,7 +88,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { }); const { data: deezerCredentials } = useQuery({ - queryKey: ["credentials", "deezer"], + queryKey: ["credentials", "deezer"], queryFn: () => fetchCredentials("deezer"), staleTime: 30000, }); @@ -98,14 +97,11 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { mutationFn: saveDownloadConfig, onSuccess: () => { toast.success("Download settings saved successfully!"); - setSaveStatus("success"); - setTimeout(() => setSaveStatus("idle"), 3000); queryClient.invalidateQueries({ queryKey: ["config"] }); }, onError: (error) => { + console.error("Failed to save settings", error.message); toast.error(`Failed to save settings: ${error.message}`); - setSaveStatus("error"); - setTimeout(() => setSaveStatus("idle"), 3000); }, }); @@ -126,12 +122,12 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { // Validation effect for watch + download method requirement useEffect(() => { let error = ""; - + // Check watch requirements if (watchConfig?.enabled && !realTime && !fallback) { error = "When watch is enabled, either Real-time downloading or Download Fallback (or both) must be enabled."; } - + // Check fallback account requirements if (fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) { const missingServices: string[] = []; @@ -139,7 +135,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { if (!deezerCredentials?.length) missingServices.push("Deezer"); error = `Download Fallback requires accounts to be configured for both services. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`; } - + setValidationError(error); }, [watchConfig?.enabled, realTime, fallback, spotifyCredentials?.length, deezerCredentials?.length]); @@ -180,12 +176,6 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
- {saveStatus === "success" && ( - Saved - )} - {saveStatus === "error" && ( - Save failed - )}
@@ -118,7 +111,9 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro

Spotify Settings

- + {globalSettings?.explicitFilter ? "Enabled" : "Disabled"} - ENV + + ENV +

diff --git a/spotizerr-ui/src/components/config/ServerTab.tsx b/spotizerr-ui/src/components/config/ServerTab.tsx index e0b288c..26c787a 100644 --- a/spotizerr-ui/src/components/config/ServerTab.tsx +++ b/spotizerr-ui/src/components/config/ServerTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useForm, Controller } from "react-hook-form"; import { authApiClient } from "../../lib/api-client"; import { toast } from "sonner"; @@ -46,20 +46,16 @@ function SpotifyApiForm() { const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ queryKey: ["spotifyApiConfig"], queryFn: fetchSpotifyApiConfig }); const { register, handleSubmit, reset } = useForm(); - const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle"); const mutation = useMutation({ mutationFn: saveSpotifyApiConfig, onSuccess: () => { toast.success("Spotify API settings saved!"); - setSaveStatus("success"); - setTimeout(() => setSaveStatus("idle"), 3000); queryClient.invalidateQueries({ queryKey: ["spotifyApiConfig"] }); }, onError: (e) => { + console.error("Failed to save Spotify API settings:", e.message); toast.error(`Failed to save: ${e.message}`); - setSaveStatus("error"); - setTimeout(() => setSaveStatus("idle"), 3000); }, }); @@ -75,12 +71,6 @@ function SpotifyApiForm() {

- {saveStatus === "success" && ( - Saved - )} - {saveStatus === "error" && ( - Save failed - )}
- + {/* Download requirements info */} {downloadConfig && (!downloadConfig.realTime && !downloadConfig.fallback) && (
-

- Download methods required -

+

Download methods required

To use watch functionality, enable either Real-time downloading or Download Fallback in the Downloads tab.

)} - + {/* Fallback account requirements info */} {downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length) && (
-

- Fallback accounts required -

+

Fallback accounts required

Download Fallback is enabled but requires accounts for both Spotify and Deezer. Configure accounts in the Accounts tab.

)} - + {/* Validation error display */} {validationError && (

{validationError}

)} - +
- + -

How often to check for new items in watchlist.

+

+ How often to check for new items in watchlist. +

- + -

Batch size per watch cycle (1–50).

+

+ Batch size per watch cycle (1–50). +

-

Artist Album Groups

-

Select which album groups to monitor for watched artists.

+

+ Artist Album Groups +

+

+ Select which album groups to monitor for watched artists. +

{ALBUM_GROUPS.map((group) => ( . Sonner auto-detects, but we can also +// explicitly set className variants for better contrast. (as needed/commented out below) +export const Toaster: React.FC = () => { + const [theme, setTheme] = useState<"light" | "dark" | "system">(getEffectiveTheme()); + + useEffect(() => { + const update = () => setTheme(getEffectiveTheme()); + window.addEventListener("app-theme-changed", update); + window.addEventListener("storage", (e) => { + if (e.key === "theme") update(); + }); + return () => { + window.removeEventListener("app-theme-changed", update); + }; + }, []); + + return ( + + ); +}; + +export default Toaster; diff --git a/spotizerr-ui/src/lib/theme.ts b/spotizerr-ui/src/lib/theme.ts index c41e4bd..aff69de 100644 --- a/spotizerr-ui/src/lib/theme.ts +++ b/spotizerr-ui/src/lib/theme.ts @@ -1,71 +1,80 @@ // Theme management functions -export function getTheme(): 'light' | 'dark' | 'system' { - return (localStorage.getItem('theme') as 'light' | 'dark' | 'system') || 'system'; +export function getTheme(): "light" | "dark" | "system" { + return (localStorage.getItem("theme") as "light" | "dark" | "system") || "system"; } -export function setTheme(theme: 'light' | 'dark' | 'system') { - localStorage.setItem('theme', theme); +export function setTheme(theme: "light" | "dark" | "system") { + localStorage.setItem("theme", theme); applyTheme(theme); + dispatchThemeChange(); } export function toggleTheme() { const currentTheme = getTheme(); - let nextTheme: 'light' | 'dark' | 'system'; - + let nextTheme: "light" | "dark" | "system"; + switch (currentTheme) { - case 'light': - nextTheme = 'dark'; + case "light": + nextTheme = "dark"; break; - case 'dark': - nextTheme = 'system'; + case "dark": + nextTheme = "system"; break; default: - nextTheme = 'light'; + nextTheme = "light"; break; } - + setTheme(nextTheme); return nextTheme; } -function applyTheme(theme: 'light' | 'dark' | 'system') { +function applyTheme(theme: "light" | "dark" | "system") { const root = document.documentElement; - - if (theme === 'system') { + + if (theme === "system") { // Use system preference - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - if (prefersDark) { - root.classList.add('dark'); - } else { - root.classList.remove('dark'); - } - } else if (theme === 'dark') { - root.classList.add('dark'); + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + if (prefersDark) root.classList.add("dark"); + else root.classList.remove("dark"); + } else if (theme === "dark") { + root.classList.add("dark"); } else { - root.classList.remove('dark'); + root.classList.remove("dark"); } } +function dispatchThemeChange() { + window.dispatchEvent(new CustomEvent("app-theme-changed")); +} + +export function getEffectiveTheme(): "light" | "dark" { + const stored = getTheme(); + if (stored === "system") { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + return stored; +} + // Dark mode detection and setup export function setupDarkMode() { // First, ensure we start with a clean slate - document.documentElement.classList.remove('dark'); - + document.documentElement.classList.remove("dark"); + const savedTheme = getTheme(); applyTheme(savedTheme); - + dispatchThemeChange(); + // Listen for system theme changes (only when using system theme) - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleSystemThemeChange = (e: MediaQueryListEvent) => { // Only respond to system changes when we're in system mode - if (getTheme() === 'system') { - if (e.matches) { - document.documentElement.classList.add('dark'); - } else { - document.documentElement.classList.remove('dark'); - } + if (getTheme() === "system") { + if (e.matches) document.documentElement.classList.add("dark"); + else document.documentElement.classList.remove("dark"); + dispatchThemeChange(); } }; - - mediaQuery.addEventListener('change', handleSystemThemeChange); -} \ No newline at end of file + + mediaQuery.addEventListener("change", handleSystemThemeChange); +} diff --git a/spotizerr-ui/src/main.tsx b/spotizerr-ui/src/main.tsx index e669b74..fb5258d 100644 --- a/spotizerr-ui/src/main.tsx +++ b/spotizerr-ui/src/main.tsx @@ -4,6 +4,7 @@ import { RouterProvider } from "@tanstack/react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { router } from "./router"; import { AuthProvider } from "./contexts/AuthProvider"; +import { Toaster } from "./components/ui/Toaster"; import { setupDarkMode } from "./lib/theme"; import "./index.css"; @@ -23,6 +24,7 @@ const queryClient = new QueryClient({ ReactDOM.createRoot(document.getElementById("root")!).render( +