Merge pull request #301 from gitmotion/enhancement/implement-toaster
Implement Toaster - Issue #300, #227
This commit is contained in:
@@ -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({
|
||||||
@@ -89,7 +88,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: deezerCredentials } = useQuery({
|
const { data: deezerCredentials } = useQuery({
|
||||||
queryKey: ["credentials", "deezer"],
|
queryKey: ["credentials", "deezer"],
|
||||||
queryFn: () => fetchCredentials("deezer"),
|
queryFn: () => fetchCredentials("deezer"),
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
});
|
});
|
||||||
@@ -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);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,12 +122,12 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
|||||||
// Validation effect for watch + download method requirement
|
// Validation effect for watch + download method requirement
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let error = "";
|
let error = "";
|
||||||
|
|
||||||
// Check watch requirements
|
// Check watch requirements
|
||||||
if (watchConfig?.enabled && !realTime && !fallback) {
|
if (watchConfig?.enabled && !realTime && !fallback) {
|
||||||
error = "When watch is enabled, either Real-time downloading or Download Fallback (or both) must be enabled.";
|
error = "When watch is enabled, either Real-time downloading or Download Fallback (or both) must be enabled.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check fallback account requirements
|
// Check fallback account requirements
|
||||||
if (fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) {
|
if (fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) {
|
||||||
const missingServices: string[] = [];
|
const missingServices: string[] = [];
|
||||||
@@ -139,7 +135,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
|||||||
if (!deezerCredentials?.length) missingServices.push("Deezer");
|
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.`;
|
error = `Download Fallback requires accounts to be configured for both services. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
setValidationError(error);
|
setValidationError(error);
|
||||||
}, [watchConfig?.enabled, realTime, fallback, spotifyCredentials?.length, deezerCredentials?.length]);
|
}, [watchConfig?.enabled, realTime, fallback, spotifyCredentials?.length, deezerCredentials?.length]);
|
||||||
|
|
||||||
@@ -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}
|
||||||
@@ -248,7 +238,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
|||||||
<p className="text-sm text-content-muted dark:text-content-muted-dark">
|
<p className="text-sm text-content-muted dark:text-content-muted-dark">
|
||||||
When enabled, downloads will be organized in user-specific subdirectories (downloads/username/...)
|
When enabled, downloads will be organized in user-specific subdirectories (downloads/username/...)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Watch validation info */}
|
{/* Watch validation info */}
|
||||||
{watchConfig?.enabled && (
|
{watchConfig?.enabled && (
|
||||||
<div className="p-3 bg-info/10 border border-info/20 rounded-lg">
|
<div className="p-3 bg-info/10 border border-info/20 rounded-lg">
|
||||||
@@ -260,7 +250,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fallback account requirements info */}
|
{/* Fallback account requirements info */}
|
||||||
{fallback && (!spotifyCredentials?.length || !deezerCredentials?.length) && (
|
{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">
|
||||||
@@ -272,7 +262,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Validation error display */}
|
{/* Validation error display */}
|
||||||
{validationError && (
|
{validationError && (
|
||||||
<div className="p-3 bg-error/10 border border-error/20 rounded-lg">
|
<div className="p-3 bg-error/10 border border-error/20 rounded-lg">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"],
|
||||||
@@ -81,7 +80,7 @@ export function WatchTab() {
|
|||||||
|
|
||||||
const { data: deezerCredentials } = useQuery({
|
const { data: deezerCredentials } = useQuery({
|
||||||
queryKey: ["credentials", "deezer"],
|
queryKey: ["credentials", "deezer"],
|
||||||
queryFn: () => fetchCredentials("deezer"),
|
queryFn: () => fetchCredentials("deezer"),
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,12 +111,12 @@ export function WatchTab() {
|
|||||||
// Validation effect for watch + download method requirement
|
// Validation effect for watch + download method requirement
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let error = "";
|
let error = "";
|
||||||
|
|
||||||
// Check if watch can be enabled (need download methods)
|
// Check if watch can be enabled (need download methods)
|
||||||
if (watchEnabled && downloadConfig && !downloadConfig.realTime && !downloadConfig.fallback) {
|
if (watchEnabled && downloadConfig && !downloadConfig.realTime && !downloadConfig.fallback) {
|
||||||
error = "To enable watch, either Real-time downloading or Download Fallback must be enabled in Download Settings.";
|
error = "To enable watch, either Real-time downloading or Download Fallback must be enabled in Download Settings.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check fallback account requirements if watch is enabled and fallback is being used
|
// Check fallback account requirements if watch is enabled and fallback is being used
|
||||||
if (watchEnabled && downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) {
|
if (watchEnabled && downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) {
|
||||||
const missingServices: string[] = [];
|
const missingServices: string[] = [];
|
||||||
@@ -134,7 +130,7 @@ export function WatchTab() {
|
|||||||
if (!error && (Number.isNaN(mir) || mir < 1 || mir > 50)) {
|
if (!error && (Number.isNaN(mir) || mir < 1 || mir > 50)) {
|
||||||
error = "Max items per run must be between 1 and 50.";
|
error = "Max items per run must be between 1 and 50.";
|
||||||
}
|
}
|
||||||
|
|
||||||
setValidationError(error);
|
setValidationError(error);
|
||||||
}, [watchEnabled, downloadConfig?.realTime, downloadConfig?.fallback, spotifyCredentials?.length, deezerCredentials?.length, maxItemsPerRunValue]);
|
}, [watchEnabled, downloadConfig?.realTime, downloadConfig?.fallback, spotifyCredentials?.length, deezerCredentials?.length, maxItemsPerRunValue]);
|
||||||
|
|
||||||
@@ -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}
|
||||||
@@ -202,40 +192,38 @@ export function WatchTab() {
|
|||||||
<label htmlFor="watchEnabledToggle" className="text-content-primary dark:text-content-primary-dark">Enable Watchlist</label>
|
<label htmlFor="watchEnabledToggle" className="text-content-primary dark:text-content-primary-dark">Enable Watchlist</label>
|
||||||
<input id="watchEnabledToggle" type="checkbox" {...register("enabled")} className="h-6 w-6 rounded" />
|
<input id="watchEnabledToggle" type="checkbox" {...register("enabled")} className="h-6 w-6 rounded" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Validation error display */}
|
{/* Validation error display */}
|
||||||
{validationError && (
|
{validationError && (
|
||||||
<div className="p-3 bg-error/10 border border-error/20 rounded-lg">
|
<div className="p-3 bg-error/10 border border-error/20 rounded-lg">
|
||||||
<p className="text-sm text-error font-medium">{validationError}</p>
|
<p className="text-sm text-error font-medium">{validationError}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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 (1–50).</p>
|
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
|
||||||
|
Batch size per watch cycle (1–50).
|
||||||
|
</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
|
||||||
|
|||||||
47
spotizerr-ui/src/components/ui/Toaster.tsx
Normal file
47
spotizerr-ui/src/components/ui/Toaster.tsx
Normal 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;
|
||||||
@@ -1,71 +1,80 @@
|
|||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(nextTheme);
|
setTheme(nextTheme);
|
||||||
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 {
|
} else if (theme === "dark") {
|
||||||
root.classList.remove('dark');
|
root.classList.add("dark");
|
||||||
}
|
|
||||||
} else if (theme === 'dark') {
|
|
||||||
root.classList.add('dark');
|
|
||||||
} else {
|
} 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
|
// 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user