From 16f0714e9b5cf972dea0c766f0ffb81825f1db76 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Wed, 12 Feb 2025 13:55:29 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20ajout=20du=20syst=C3=A8me=20de=20notifi?= =?UTF-8?q?cations=20toast=20et=20am=C3=A9lioration=20du=20cache=20avec=20?= =?UTF-8?q?TTL=20configurable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 56 +++++++ package.json | 1 + src/components/layout/ClientLayout.tsx | 2 + src/components/ui/toast.tsx | 122 +++++++++++++++ src/components/ui/toaster.tsx | 31 ++++ src/components/ui/use-toast.ts | 186 +++++++++++++++++++++++ src/lib/services/base-api.service.ts | 9 +- src/lib/services/server-cache.service.ts | 43 ++++-- src/lib/services/storage.service.ts | 31 ++++ 9 files changed, 459 insertions(+), 22 deletions(-) create mode 100644 src/components/ui/toast.tsx create mode 100644 src/components/ui/toaster.tsx create mode 100644 src/components/ui/use-toast.ts diff --git a/package-lock.json b/package-lock.json index 1190fa6..85a5a2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.2.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "lucide-react": "^0.323.0", @@ -934,6 +935,39 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.6.tgz", + "integrity": "sha512-gN4dpuIVKEgpLn1z5FhzT9mYRUitbfZq9XqN/7kkBMUgFTzTG8x/KszWJugJXHcwxckY8xcKDZPz7kG3o6DsUA==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", @@ -1036,6 +1070,28 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz", + "integrity": "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", diff --git a/package.json b/package.json index 51b2660..e45bc02 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.2.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "lucide-react": "^0.323.0", diff --git a/src/components/layout/ClientLayout.tsx b/src/components/layout/ClientLayout.tsx index 1580ca6..4e19875 100644 --- a/src/components/layout/ClientLayout.tsx +++ b/src/components/layout/ClientLayout.tsx @@ -4,6 +4,7 @@ import { ThemeProvider } from "next-themes"; import { useState, useEffect } from "react"; import { Header } from "@/components/layout/Header"; import { Sidebar } from "@/components/layout/Sidebar"; +import { Toaster } from "@/components/ui/toaster"; export default function ClientLayout({ children }: { children: React.ReactNode }) { const [isSidebarOpen, setIsSidebarOpen] = useState(false); @@ -39,6 +40,7 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
setIsSidebarOpen(!isSidebarOpen)} />
{children}
+ ); diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000..30519df --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,122 @@ +import * as React from "react"; +import * as ToastPrimitives from "@radix-ui/react-toast"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const ToastProvider = ToastPrimitives.Provider; + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastViewport.displayName = ToastPrimitives.Viewport.displayName; + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; + +type ToastProps = React.ComponentPropsWithoutRef; + +type ToastActionElement = React.ReactElement; + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +}; diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 0000000..64c97fd --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -0,0 +1,31 @@ +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast"; +import { useToast } from "@/components/ui/use-toast"; + +export function Toaster() { + const { toasts } = useToast(); + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && {description}} +
+ {action} + +
+ ); + })} + +
+ ); +} diff --git a/src/components/ui/use-toast.ts b/src/components/ui/use-toast.ts new file mode 100644 index 0000000..10746f8 --- /dev/null +++ b/src/components/ui/use-toast.ts @@ -0,0 +1,186 @@ +import * as React from "react"; + +import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; + +const TOAST_LIMIT = 1; +const TOAST_REMOVE_DELAY = 1000000; + +type ToasterToast = ToastProps & { + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const; + +let count = 0; + +function genId() { + count = (count + 1) % Number.MAX_VALUE; + return count.toString(); +} + +type ActionType = typeof actionTypes; + +type Action = + | { + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; + } + | { + type: ActionType["UPDATE_TOAST"]; + toast: Partial; + } + | { + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; + } + | { + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; + +interface State { + toasts: ToasterToast[]; +} + +const toastTimeouts = new Map>(); + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }); + }, TOAST_REMOVE_DELAY); + + toastTimeouts.set(toastId, timeout); +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)), + }; + + case "DISMISS_TOAST": { + const { toastId } = action; + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + }; + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +}; + +const listeners: Array<(state: State) => void> = []; + +let memoryState: State = { toasts: [] }; + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); +} + +type Toast = Omit; + +function toast({ ...props }: Toast) { + const id = genId(); + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, + }, + }); + + return { + id: id, + dismiss, + update, + }; +} + +function useToast() { + const [state, setState] = React.useState(memoryState); + + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + }; +} + +export { useToast, toast }; diff --git a/src/lib/services/base-api.service.ts b/src/lib/services/base-api.service.ts index 4e7817f..88f533d 100644 --- a/src/lib/services/base-api.service.ts +++ b/src/lib/services/base-api.service.ts @@ -3,14 +3,7 @@ import { AuthConfig } from "@/types/auth"; import { serverCacheService } from "./server-cache.service"; // Types de cache disponibles -export type CacheType = - | "DEFAULT" - | "HOME" - | "LIBRARIES" - | "SERIES" - | "BOOKS" - | "IMAGES" - | "READ_PROGRESS"; +export type CacheType = "DEFAULT" | "HOME" | "LIBRARIES" | "SERIES" | "BOOKS" | "IMAGES"; export abstract class BaseApiService { protected static async getKomgaConfig(): Promise { diff --git a/src/lib/services/server-cache.service.ts b/src/lib/services/server-cache.service.ts index 6e7d218..2a97eda 100644 --- a/src/lib/services/server-cache.service.ts +++ b/src/lib/services/server-cache.service.ts @@ -15,14 +15,13 @@ class ServerCacheService { private static readonly noCache = 0; // Configuration des temps de cache en secondes - private static readonly TTL = { - DEFAULT: ServerCacheService.fiveMinutes, // 5 minutes - HOME: ServerCacheService.oneMinute, // 1 minute - LIBRARIES: ServerCacheService.tenMinutes, // 10 minutes - SERIES: ServerCacheService.fiveMinutes, // 5 minutes - BOOKS: ServerCacheService.fiveMinutes, // 5 minutes - IMAGES: ServerCacheService.twentyFourHours, // 24 heures - READ_PROGRESS: ServerCacheService.oneMinute, // 1 minute + private static readonly DEFAULT_TTL = { + DEFAULT: ServerCacheService.fiveMinutes, + HOME: ServerCacheService.fiveMinutes, + LIBRARIES: ServerCacheService.twentyFourHours, + SERIES: ServerCacheService.fiveMinutes, + BOOKS: ServerCacheService.fiveMinutes, + IMAGES: ServerCacheService.twentyFourHours, }; private constructor() { @@ -39,17 +38,33 @@ class ServerCacheService { /** * Retourne le TTL pour un type de données spécifique */ - public getTTL(type: keyof typeof ServerCacheService.TTL): number { - return ServerCacheService.TTL[type]; + public getTTL(type: keyof typeof ServerCacheService.DEFAULT_TTL): number { + // Essayer de récupérer la configuration utilisateur + try { + const ttlConfig = localStorage.getItem("ttlConfig"); + if (ttlConfig) { + const config = JSON.parse(ttlConfig); + const key = `${type.toLowerCase()}TTL` as keyof typeof config; + if (config[key]) { + // Convertir les minutes en secondes + return config[key] * 60; + } + } + } catch (error) { + console.error("Erreur lors de la lecture de la configuration TTL:", error); + } + + // Utiliser la valeur par défaut si pas de configuration utilisateur + return ServerCacheService.DEFAULT_TTL[type]; } /** * Met en cache des données avec une durée de vie */ - set(key: string, data: any, type: keyof typeof ServerCacheService.TTL = "DEFAULT"): void { + set(key: string, data: any, type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT"): void { this.cache.set(key, { data, - expiry: Date.now() + ServerCacheService.TTL[type] * 1000, + expiry: Date.now() + this.getTTL(type) * 1000, }); } @@ -89,7 +104,7 @@ class ServerCacheService { async getOrSet( key: string, fetcher: () => Promise, - type: keyof typeof ServerCacheService.TTL = "DEFAULT" + type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT" ): Promise { const now = Date.now(); const cached = this.cache.get(key); @@ -103,7 +118,7 @@ class ServerCacheService { const data = await fetcher(); this.cache.set(key, { data, - expiry: now + ServerCacheService.TTL[type] * 1000, + expiry: now + this.getTTL(type) * 1000, }); return data; } catch (error) { diff --git a/src/lib/services/storage.service.ts b/src/lib/services/storage.service.ts index e618014..9ed251f 100644 --- a/src/lib/services/storage.service.ts +++ b/src/lib/services/storage.service.ts @@ -2,6 +2,16 @@ import { AuthConfig } from "@/types/auth"; const CREDENTIALS_KEY = "komgaCredentials"; const USER_KEY = "komgaUser"; +const TTL_CONFIG_KEY = "ttlConfig"; + +interface TTLConfig { + defaultTTL: number; + homeTTL: number; + librariesTTL: number; + seriesTTL: number; + booksTTL: number; + imagesTTL: number; +} class StorageService { private static instance: StorageService; @@ -100,6 +110,27 @@ class StorageService { } } + /** + * Stocke la configuration des TTL + */ + setTTLConfig(config: TTLConfig): void { + localStorage.setItem(TTL_CONFIG_KEY, JSON.stringify(config)); + } + + /** + * Récupère la configuration des TTL + */ + getTTLConfig(): TTLConfig | null { + const stored = localStorage.getItem(TTL_CONFIG_KEY); + if (!stored) return null; + + try { + return JSON.parse(stored); + } catch { + return null; + } + } + /** * Efface toutes les données stockées */