Merge pull request #301 from gitmotion/enhancement/implement-toaster

Implement Toaster - Issue #300, #227
This commit is contained in:
Spotizerr
2025-08-22 11:25:14 -06:00
committed by GitHub
8 changed files with 155 additions and 140 deletions

View File

@@ -53,7 +53,7 @@ const CONVERSION_FORMATS: Record<string, string[]> = {
// --- API Functions --- // --- API Functions ---
const saveDownloadConfig = async (data: Partial<DownloadSettings>) => { const saveDownloadConfig = async (data: Partial<DownloadSettings>) => {
const payload: any = { ...data }; const payload: Partial<DownloadSettings> = { ...data };
const { data: response } = await authApiClient.client.post("/config", payload); const { data: response } = await authApiClient.client.post("/config", payload);
return response; return response;
}; };
@@ -72,7 +72,6 @@ const fetchCredentials = async (service: "spotify" | "deezer"): Promise<Credenti
export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [validationError, setValidationError] = useState<string>(""); const [validationError, setValidationError] = useState<string>("");
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
// Fetch watch config // Fetch watch config
const { data: watchConfig } = useQuery({ const { data: watchConfig } = useQuery({
@@ -98,14 +97,11 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
mutationFn: saveDownloadConfig, mutationFn: saveDownloadConfig,
onSuccess: () => { onSuccess: () => {
toast.success("Download settings saved successfully!"); toast.success("Download settings saved successfully!");
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
queryClient.invalidateQueries({ queryKey: ["config"] }); queryClient.invalidateQueries({ queryKey: ["config"] });
}, },
onError: (error) => { onError: (error) => {
console.error("Failed to save settings", error.message);
toast.error(`Failed to save settings: ${error.message}`); toast.error(`Failed to save settings: ${error.message}`);
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 3000);
}, },
}); });
@@ -180,12 +176,6 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="flex items-center justify-end mb-4"> <div className="flex items-center justify-end mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{saveStatus === "success" && (
<span className="text-success text-sm">Saved</span>
)}
{saveStatus === "error" && (
<span className="text-error text-sm">Save failed</span>
)}
<button <button
type="submit" type="submit"
disabled={mutation.isPending || !!validationError} disabled={mutation.isPending || !!validationError}

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from "react"; import { useRef } from "react";
import { useForm, type SubmitHandler } from "react-hook-form"; import { useForm, type SubmitHandler } from "react-hook-form";
import { authApiClient } from "../../lib/api-client"; import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -80,20 +80,16 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const dirInputRef = useRef<HTMLInputElement | null>(null); const dirInputRef = useRef<HTMLInputElement | null>(null);
const trackInputRef = useRef<HTMLInputElement | null>(null); const trackInputRef = useRef<HTMLInputElement | null>(null);
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const mutation = useMutation({ const mutation = useMutation({
mutationFn: saveFormattingConfig, mutationFn: saveFormattingConfig,
onSuccess: () => { onSuccess: () => {
toast.success("Formatting settings saved!"); toast.success("Formatting settings saved!");
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
queryClient.invalidateQueries({ queryKey: ["config"] }); queryClient.invalidateQueries({ queryKey: ["config"] });
}, },
onError: (error) => { onError: (error) => {
console.error("Failed to save formatting settings:", error.message);
toast.error(`Failed to save settings: ${error.message}`); toast.error(`Failed to save settings: ${error.message}`);
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 3000);
}, },
}); });
@@ -131,12 +127,6 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="flex items-center justify-end mb-4"> <div className="flex items-center justify-end mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{saveStatus === "success" && (
<span className="text-success text-sm">Saved</span>
)}
{saveStatus === "error" && (
<span className="text-error text-sm">Save failed</span>
)}
<button <button
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending}

View File

@@ -3,7 +3,7 @@ import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner"; import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "../../contexts/settings-context"; import { useSettings } from "../../contexts/settings-context";
import { useEffect, useState } from "react"; import { useEffect } from "react";
// --- Type Definitions --- // --- Type Definitions ---
interface Credential { interface Credential {
@@ -56,20 +56,15 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
} }
}, [config, reset]); }, [config, reset]);
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const mutation = useMutation({ const mutation = useMutation({
mutationFn: saveGeneralConfig, mutationFn: saveGeneralConfig,
onSuccess: () => { onSuccess: () => {
toast.success("General settings saved!"); toast.success("General settings saved!");
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
queryClient.invalidateQueries({ queryKey: ["config"] }); queryClient.invalidateQueries({ queryKey: ["config"] });
}, },
onError: (e: Error) => { onError: (e: Error) => {
console.error("Failed to save general settings:", e.message);
toast.error(`Failed to save: ${e.message}`); toast.error(`Failed to save: ${e.message}`);
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 3000);
}, },
}); });
@@ -84,12 +79,6 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="flex items-center justify-end mb-4"> <div className="flex items-center justify-end mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{saveStatus === "success" && (
<span className="text-success text-sm">Saved</span>
)}
{saveStatus === "error" && (
<span className="text-error text-sm">Save failed</span>
)}
<button <button
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending}
@@ -103,14 +92,18 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Service Defaults</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Service Defaults</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="service" className="text-content-primary dark:text-content-primary-dark">Default Service</label> <label htmlFor="service" className="text-content-primary dark:text-content-primary-dark">
Default Service
</label>
<select <select
id="service" id="service"
{...register("service")} {...register("service")}
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
> >
<option value="spotify">Spotify</option> <option value="spotify">Spotify</option>
<option value="deezer" disabled>Deezer (not yet...)</option> <option value="deezer" disabled>
Deezer (not yet...)
</option>
</select> </select>
</div> </div>
</div> </div>
@@ -118,7 +111,9 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Spotify Settings</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Spotify Settings</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="spotifyAccount" className="text-content-primary dark:text-content-primary-dark">Active Spotify Account</label> <label htmlFor="spotifyAccount" className="text-content-primary dark:text-content-primary-dark">
Active Spotify Account
</label>
<select <select
id="spotifyAccount" id="spotifyAccount"
{...register("spotify")} {...register("spotify")}
@@ -136,7 +131,9 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Deezer Settings</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Deezer Settings</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="deezerAccount" className="text-content-primary dark:text-content-primary-dark">Active Deezer Account</label> <label htmlFor="deezerAccount" className="text-content-primary dark:text-content-primary-dark">
Active Deezer Account
</label>
<select <select
id="deezerAccount" id="deezerAccount"
{...register("deezer")} {...register("deezer")}
@@ -159,7 +156,9 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
<span className={`font-semibold ${globalSettings?.explicitFilter ? "text-success" : "text-error"}`}> <span className={`font-semibold ${globalSettings?.explicitFilter ? "text-success" : "text-error"}`}>
{globalSettings?.explicitFilter ? "Enabled" : "Disabled"} {globalSettings?.explicitFilter ? "Enabled" : "Disabled"}
</span> </span>
<span className="text-xs bg-surface-accent dark:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark px-2 py-1 rounded-full">ENV</span> <span className="text-xs bg-surface-accent dark:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark px-2 py-1 rounded-full">
ENV
</span>
</div> </div>
</div> </div>
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1"> <p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { authApiClient } from "../../lib/api-client"; import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -46,20 +46,16 @@ function SpotifyApiForm() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["spotifyApiConfig"], queryFn: fetchSpotifyApiConfig }); const { data, isLoading } = useQuery({ queryKey: ["spotifyApiConfig"], queryFn: fetchSpotifyApiConfig });
const { register, handleSubmit, reset } = useForm<SpotifyApiSettings>(); const { register, handleSubmit, reset } = useForm<SpotifyApiSettings>();
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const mutation = useMutation({ const mutation = useMutation({
mutationFn: saveSpotifyApiConfig, mutationFn: saveSpotifyApiConfig,
onSuccess: () => { onSuccess: () => {
toast.success("Spotify API settings saved!"); toast.success("Spotify API settings saved!");
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
queryClient.invalidateQueries({ queryKey: ["spotifyApiConfig"] }); queryClient.invalidateQueries({ queryKey: ["spotifyApiConfig"] });
}, },
onError: (e) => { onError: (e) => {
console.error("Failed to save Spotify API settings:", e.message);
toast.error(`Failed to save: ${e.message}`); toast.error(`Failed to save: ${e.message}`);
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 3000);
}, },
}); });
@@ -75,12 +71,6 @@ function SpotifyApiForm() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="flex items-center justify-end mb-2"> <div className="flex items-center justify-end mb-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{saveStatus === "success" && (
<span className="text-success text-sm">Saved</span>
)}
{saveStatus === "error" && (
<span className="text-error text-sm">Save failed</span>
)}
<button <button
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending}
@@ -120,20 +110,16 @@ function WebhookForm() {
const { data, isLoading } = useQuery({ queryKey: ["webhookConfig"], queryFn: fetchWebhookConfig }); const { data, isLoading } = useQuery({ queryKey: ["webhookConfig"], queryFn: fetchWebhookConfig });
const { register, handleSubmit, control, reset, watch } = useForm<WebhookSettings>(); const { register, handleSubmit, control, reset, watch } = useForm<WebhookSettings>();
const currentUrl = watch("url"); const currentUrl = watch("url");
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const mutation = useMutation({ const mutation = useMutation({
mutationFn: saveWebhookConfig, mutationFn: saveWebhookConfig,
onSuccess: () => { onSuccess: () => {
// No toast needed since the function shows one // No toast needed since the function shows one
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
queryClient.invalidateQueries({ queryKey: ["webhookConfig"] }); queryClient.invalidateQueries({ queryKey: ["webhookConfig"] });
}, },
onError: (e) => { onError: (e) => {
toast.error(`Failed to save: ${e.message}`); toast.error(`Failed to save: ${e.message}`);
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 3000);
}, },
}); });
@@ -157,12 +143,6 @@ function WebhookForm() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="flex items-center justify-end mb-2"> <div className="flex items-center justify-end mb-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{saveStatus === "success" && (
<span className="text-success text-sm">Saved</span>
)}
{saveStatus === "error" && (
<span className="text-error text-sm">Save failed</span>
)}
<button <button
type="submit" type="submit"
disabled={mutation.isPending} disabled={mutation.isPending}

View File

@@ -58,7 +58,6 @@ const saveWatchConfig = async (data: Partial<WatchSettings>) => {
export function WatchTab() { export function WatchTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [validationError, setValidationError] = useState<string>(""); const [validationError, setValidationError] = useState<string>("");
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const { data: config, isLoading } = useQuery({ const { data: config, isLoading } = useQuery({
queryKey: ["watchConfig"], queryKey: ["watchConfig"],
@@ -89,15 +88,12 @@ export function WatchTab() {
mutationFn: saveWatchConfig, mutationFn: saveWatchConfig,
onSuccess: () => { onSuccess: () => {
toast.success("Watch settings saved successfully!"); toast.success("Watch settings saved successfully!");
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
queryClient.invalidateQueries({ queryKey: ["watchConfig"] }); queryClient.invalidateQueries({ queryKey: ["watchConfig"] });
}, },
onError: (error: any) => { onError: (error: any) => {
const message = error?.response?.data?.error || error?.message || "Unknown error"; const message = error?.response?.data?.error || error?.message || "Unknown error";
toast.error(`Failed to save settings: ${message}`); toast.error(`Failed to save settings: ${message}`);
setSaveStatus("error"); console.error("Failed to save watch settings:", message);
setTimeout(() => setSaveStatus("idle"), 3000);
}, },
}); });
@@ -180,12 +176,6 @@ export function WatchTab() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="flex items-center justify-end mb-4"> <div className="flex items-center justify-end mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{saveStatus === "success" && (
<span className="text-success text-sm">Saved</span>
)}
{saveStatus === "error" && (
<span className="text-error text-sm">Save failed</span>
)}
<button <button
type="submit" type="submit"
disabled={mutation.isPending || !!validationError} disabled={mutation.isPending || !!validationError}
@@ -206,9 +196,7 @@ export function WatchTab() {
{/* Download requirements info */} {/* Download requirements info */}
{downloadConfig && (!downloadConfig.realTime && !downloadConfig.fallback) && ( {downloadConfig && (!downloadConfig.realTime && !downloadConfig.fallback) && (
<div className="p-3 bg-warning/10 border border-warning/20 rounded-lg"> <div className="p-3 bg-warning/10 border border-warning/20 rounded-lg">
<p className="text-sm text-warning font-medium mb-1"> <p className="text-sm text-warning font-medium mb-1">Download methods required</p>
Download methods required
</p>
<p className="text-xs text-content-muted dark:text-content-muted-dark"> <p className="text-xs text-content-muted dark:text-content-muted-dark">
To use watch functionality, enable either Real-time downloading or Download Fallback in the Downloads tab. To use watch functionality, enable either Real-time downloading or Download Fallback in the Downloads tab.
</p> </p>
@@ -218,9 +206,7 @@ export function WatchTab() {
{/* Fallback account requirements info */} {/* Fallback account requirements info */}
{downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length) && ( {downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length) && (
<div className="p-3 bg-warning/10 border border-warning/20 rounded-lg"> <div className="p-3 bg-warning/10 border border-warning/20 rounded-lg">
<p className="text-sm text-warning font-medium mb-1"> <p className="text-sm text-warning font-medium mb-1">Fallback accounts required</p>
Fallback accounts required
</p>
<p className="text-xs text-content-muted dark:text-content-muted-dark"> <p className="text-xs text-content-muted dark:text-content-muted-dark">
Download Fallback is enabled but requires accounts for both Spotify and Deezer. Configure accounts in the Accounts tab. Download Fallback is enabled but requires accounts for both Spotify and Deezer. Configure accounts in the Accounts tab.
</p> </p>
@@ -235,7 +221,9 @@ export function WatchTab() {
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="watchPollIntervalSeconds" className="text-content-primary dark:text-content-primary-dark">Watch Poll Interval (seconds)</label> <label htmlFor="watchPollIntervalSeconds" className="text-content-primary dark:text-content-primary-dark">
Watch Poll Interval (seconds)
</label>
<input <input
id="watchPollIntervalSeconds" id="watchPollIntervalSeconds"
type="number" type="number"
@@ -243,11 +231,15 @@ export function WatchTab() {
{...register("watchPollIntervalSeconds")} {...register("watchPollIntervalSeconds")}
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/> />
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">How often to check for new items in watchlist.</p> <p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
How often to check for new items in watchlist.
</p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="maxItemsPerRun" className="text-content-primary dark:text-content-primary-dark">Max Items Per Run</label> <label htmlFor="maxItemsPerRun" className="text-content-primary dark:text-content-primary-dark">
Max Items Per Run
</label>
<input <input
id="maxItemsPerRun" id="maxItemsPerRun"
type="number" type="number"
@@ -256,13 +248,19 @@ export function WatchTab() {
{...register("maxItemsPerRun")} {...register("maxItemsPerRun")}
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus" className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
/> />
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">Batch size per watch cycle (150).</p> <p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
Batch size per watch cycle (150).
</p>
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Artist Album Groups</h3> <h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">
<p className="text-sm text-content-muted dark:text-content-muted-dark">Select which album groups to monitor for watched artists.</p> Artist Album Groups
</h3>
<p className="text-sm text-content-muted dark:text-content-muted-dark">
Select which album groups to monitor for watched artists.
</p>
<div className="grid grid-cols-2 gap-4 pt-2"> <div className="grid grid-cols-2 gap-4 pt-2">
{ALBUM_GROUPS.map((group) => ( {ALBUM_GROUPS.map((group) => (
<Controller <Controller

View File

@@ -0,0 +1,47 @@
import React, { useEffect, useState } from "react";
import { Toaster as SonnerToaster } from "sonner";
import { getEffectiveTheme } from "@/lib/theme";
// Centralized Toaster wrapper so we can control defaults + theme.
// Tailwind dark mode relies on .dark on <html>. 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 (
<SonnerToaster
position="top-center"
theme={theme}
richColors
toastOptions={{
duration: 3000,
classNames: {
// toast:
// "bg-white dark:bg-surface-secondary-dark text-content-primary dark:text-content-primary-dark border border-line dark:border-border-dark shadow-md",
title: "font-medium",
description: "text-content-secondary dark:text-content-secondary-dark",
// success: "bg-success/10 dark:bg-success/20 text-success border-success/40",
// error: "bg-error/10 dark:bg-error/20 text-error border-error/40",
// warning: "bg-warning/10 dark:bg-warning/20 text-warning border-warning/40",
// info: "bg-info/10 dark:bg-info/20 text-info border-info/40",
closeButton:
"text-content-muted dark:text-content-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark",
actionButton: "bg-primary text-white hover:bg-primary-hover",
},
}}
/>
);
};
export default Toaster;

View File

@@ -1,26 +1,27 @@
// Theme management functions // Theme management functions
export function getTheme(): 'light' | 'dark' | 'system' { export function getTheme(): "light" | "dark" | "system" {
return (localStorage.getItem('theme') as 'light' | 'dark' | 'system') || 'system'; return (localStorage.getItem("theme") as "light" | "dark" | "system") || "system";
} }
export function setTheme(theme: 'light' | 'dark' | 'system') { export function setTheme(theme: "light" | "dark" | "system") {
localStorage.setItem('theme', theme); localStorage.setItem("theme", theme);
applyTheme(theme); applyTheme(theme);
dispatchThemeChange();
} }
export function toggleTheme() { export function toggleTheme() {
const currentTheme = getTheme(); const currentTheme = getTheme();
let nextTheme: 'light' | 'dark' | 'system'; let nextTheme: "light" | "dark" | "system";
switch (currentTheme) { switch (currentTheme) {
case 'light': case "light":
nextTheme = 'dark'; nextTheme = "dark";
break; break;
case 'dark': case "dark":
nextTheme = 'system'; nextTheme = "system";
break; break;
default: default:
nextTheme = 'light'; nextTheme = "light";
break; break;
} }
@@ -28,44 +29,52 @@ export function toggleTheme() {
return nextTheme; return nextTheme;
} }
function applyTheme(theme: 'light' | 'dark' | 'system') { function applyTheme(theme: "light" | "dark" | "system") {
const root = document.documentElement; const root = document.documentElement;
if (theme === 'system') { if (theme === "system") {
// Use system preference // Use system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (prefersDark) { if (prefersDark) root.classList.add("dark");
root.classList.add('dark'); else root.classList.remove("dark");
} else if (theme === "dark") {
root.classList.add("dark");
} else { } else {
root.classList.remove('dark'); root.classList.remove("dark");
} }
} else if (theme === 'dark') {
root.classList.add('dark');
} else {
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 // Dark mode detection and setup
export function setupDarkMode() { export function setupDarkMode() {
// First, ensure we start with a clean slate // First, ensure we start with a clean slate
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove("dark");
const savedTheme = getTheme(); const savedTheme = getTheme();
applyTheme(savedTheme); applyTheme(savedTheme);
dispatchThemeChange();
// Listen for system theme changes (only when using system theme) // 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) => { const handleSystemThemeChange = (e: MediaQueryListEvent) => {
// Only respond to system changes when we're in system mode // Only respond to system changes when we're in system mode
if (getTheme() === 'system') { if (getTheme() === "system") {
if (e.matches) { if (e.matches) document.documentElement.classList.add("dark");
document.documentElement.classList.add('dark'); else document.documentElement.classList.remove("dark");
} else { dispatchThemeChange();
document.documentElement.classList.remove('dark');
}
} }
}; };
mediaQuery.addEventListener('change', handleSystemThemeChange); mediaQuery.addEventListener("change", handleSystemThemeChange);
} }

View File

@@ -4,6 +4,7 @@ import { RouterProvider } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { router } from "./router"; import { router } from "./router";
import { AuthProvider } from "./contexts/AuthProvider"; import { AuthProvider } from "./contexts/AuthProvider";
import { Toaster } from "./components/ui/Toaster";
import { setupDarkMode } from "./lib/theme"; import { setupDarkMode } from "./lib/theme";
import "./index.css"; import "./index.css";
@@ -23,6 +24,7 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Toaster />
<AuthProvider> <AuthProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</AuthProvider> </AuthProvider>