mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
a97979358f
Adds file hash-based matching for Audiobookshelf library items to ensure 100% accurate ASIN assignment for RMAB-organized content. Removes fuzzy matching from library availability checks, making all matching ASIN-only to eliminate false positives and race conditions. Updates database schema, processors, and matcher utilities; adds new tests and documentation for the new matching strategy. Removes obsolete scripts, Dockerfile, and related tests; updates docker-compose for test environments.
195 lines
5.5 KiB
TypeScript
195 lines
5.5 KiB
TypeScript
/**
|
|
* Component: Toast Notification System
|
|
* Documentation: documentation/frontend/components.md
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
|
|
|
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
|
|
|
export interface Toast {
|
|
id: string;
|
|
message: string;
|
|
type: ToastType;
|
|
duration?: number;
|
|
}
|
|
|
|
interface ToastContextType {
|
|
toasts: Toast[];
|
|
addToast: (message: string, type: ToastType, duration?: number) => void;
|
|
removeToast: (id: string) => void;
|
|
success: (message: string, duration?: number) => void;
|
|
error: (message: string, duration?: number) => void;
|
|
info: (message: string, duration?: number) => void;
|
|
warning: (message: string, duration?: number) => void;
|
|
}
|
|
|
|
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
|
|
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
|
|
const removeToast = useCallback((id: string) => {
|
|
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
|
}, []);
|
|
|
|
const addToast = useCallback(
|
|
(message: string, type: ToastType, duration: number = 5000) => {
|
|
const id = `toast-${Date.now()}-${Math.random()}`;
|
|
const newToast: Toast = { id, message, type, duration };
|
|
|
|
setToasts((prev) => [...prev, newToast]);
|
|
|
|
if (duration > 0) {
|
|
setTimeout(() => {
|
|
removeToast(id);
|
|
}, duration);
|
|
}
|
|
},
|
|
[removeToast]
|
|
);
|
|
|
|
const success = useCallback(
|
|
(message: string, duration?: number) => addToast(message, 'success', duration),
|
|
[addToast]
|
|
);
|
|
|
|
const error = useCallback(
|
|
(message: string, duration?: number) => addToast(message, 'error', duration),
|
|
[addToast]
|
|
);
|
|
|
|
const info = useCallback(
|
|
(message: string, duration?: number) => addToast(message, 'info', duration),
|
|
[addToast]
|
|
);
|
|
|
|
const warning = useCallback(
|
|
(message: string, duration?: number) => addToast(message, 'warning', duration),
|
|
[addToast]
|
|
);
|
|
|
|
return (
|
|
<ToastContext.Provider
|
|
value={{
|
|
toasts,
|
|
addToast,
|
|
removeToast,
|
|
success,
|
|
error,
|
|
info,
|
|
warning,
|
|
}}
|
|
>
|
|
{children}
|
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
|
</ToastContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useToast() {
|
|
const context = useContext(ToastContext);
|
|
if (context === undefined) {
|
|
throw new Error('useToast must be used within a ToastProvider');
|
|
}
|
|
return context;
|
|
}
|
|
|
|
function ToastContainer({
|
|
toasts,
|
|
onRemove,
|
|
}: {
|
|
toasts: Toast[];
|
|
onRemove: (id: string) => void;
|
|
}) {
|
|
if (toasts.length === 0) return null;
|
|
|
|
return (
|
|
<div className="fixed top-4 right-4 z-50 space-y-2 pointer-events-none">
|
|
{toasts.map((toast) => (
|
|
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: (id: string) => void }) {
|
|
const colors = {
|
|
success: 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200',
|
|
error: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200',
|
|
warning: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200',
|
|
info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200',
|
|
};
|
|
|
|
const icons = {
|
|
success: (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
),
|
|
error: (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
),
|
|
warning: (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
/>
|
|
</svg>
|
|
),
|
|
info: (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
),
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`flex items-start gap-3 px-4 py-3 rounded-lg border shadow-lg min-w-[300px] max-w-md pointer-events-auto animate-slide-in-right ${
|
|
colors[toast.type]
|
|
}`}
|
|
>
|
|
<div className="flex-shrink-0">{icons[toast.type]}</div>
|
|
<div className="flex-1 text-sm font-medium">{toast.message}</div>
|
|
<button
|
|
onClick={() => onRemove(toast.id)}
|
|
className="flex-shrink-0 hover:opacity-70 transition-opacity"
|
|
aria-label="Close"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|