Files
lidify/frontend/lib/toast-context.tsx
Your Name cc8d0f6969 Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features:
- Multi-source download system (Soulseek/Lidarr with fallback)
- Configurable enrichment speed control (1-5x)
- Mobile touch drag support for seek sliders
- iOS PWA media controls (Control Center, Lock Screen)
- Artist name alias resolution via Last.fm
- Circuit breaker pattern for audio analysis

Critical Fixes:
- Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM)
- Discovery system race conditions and import failures
- Radio decade categorization using originalYear
- LastFM API response normalization
- Mood bucket infinite loop prevention

Security:
- Bull Board admin authentication
- Lidarr webhook signature verification
- JWT token expiration and refresh
- Encryption key validation on startup

Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
2026-01-06 20:07:33 -06:00

155 lines
4.7 KiB
TypeScript

"use client";
import {
createContext,
useContext,
useState,
useCallback,
ReactNode,
useRef,
useEffect,
} from "react";
import { CheckCircle2, XCircle, AlertCircle, Info, X } from "lucide-react";
import { cn } from "@/utils/cn";
type ToastType = "success" | "error" | "warning" | "info";
interface Toast {
id: string;
type: ToastType;
message: string;
action?: {
label: string;
onClick: () => void;
};
}
interface ToastContextType {
toast: {
success: (message: string) => void;
error: (message: string) => void;
warning: (message: string) => void;
info: (message: string) => void;
};
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within ToastProvider");
}
return context;
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const timeoutsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
const addToast = useCallback((type: ToastType, message: string) => {
// Use a more unique ID that combines timestamp, counter, and random value
const id = `${Date.now()}-${Math.random()
.toString(36)
.substring(2, 9)}`;
setToasts((prev) => [...prev, { id, type, message }]);
// Auto-dismiss after 5 seconds and store timeout ID
const timeoutId = setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
timeoutsRef.current.delete(id);
}, 5000);
timeoutsRef.current.set(id, timeoutId);
}, []);
const removeToast = useCallback((id: string) => {
// Clear the timeout if toast is manually dismissed
const timeoutId = timeoutsRef.current.get(id);
if (timeoutId) {
clearTimeout(timeoutId);
timeoutsRef.current.delete(id);
}
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
// Cleanup all timeouts on unmount
useEffect(() => {
return () => {
timeoutsRef.current.forEach((timeoutId) => {
clearTimeout(timeoutId);
});
timeoutsRef.current.clear();
};
}, []);
const toast = {
success: (message: string) => addToast("success", message),
error: (message: string) => addToast("error", message),
warning: (message: string) => addToast("warning", message),
info: (message: string) => addToast("info", message),
};
return (
<ToastContext.Provider value={{ toast }}>
{children}
{/* Toast Container */}
<div className="fixed top-4 right-4 z-[100] space-y-2 max-w-sm w-full px-4 md:px-0" aria-live="polite" aria-atomic="false">
{toasts.map((t) => (
<ToastItem
key={t.id}
toast={t}
onClose={() => removeToast(t.id)}
/>
))}
</div>
</ToastContext.Provider>
);
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
const icons = {
success: CheckCircle2,
error: XCircle,
warning: AlertCircle,
info: Info,
};
const styles = {
success:
"bg-gradient-to-br from-[#141414] to-[#0f0f0f] border-green-500/50 text-green-500",
error: "bg-gradient-to-br from-[#141414] to-[#0f0f0f] border-red-500/50 text-red-500",
warning:
"bg-gradient-to-br from-[#141414] to-[#0f0f0f] border-yellow-500/50 text-yellow-500",
info: "bg-gradient-to-br from-[#141414] to-[#0f0f0f] border-blue-500/50 text-blue-500",
};
const Icon = icons[toast.type];
return (
<div
role={toast.type === "error" ? "alert" : "status"}
aria-live={toast.type === "error" ? "assertive" : "polite"}
aria-atomic="true"
className={cn(
"flex items-start gap-3 p-4 rounded-sm border shadow-2xl animate-in slide-in-from-right duration-300",
styles[toast.type]
)}
>
<Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />
<p className="flex-1 text-sm text-white font-medium">
{toast.message}
</p>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
);
}