diff --git a/package.json b/package.json index 3eed2a9..798b732 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-toast": "1.2.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15a5043..4a3deed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@radix-ui/react-progress': specifier: ^1.1.2 version: 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-radio-group': + specifier: ^1.3.8 + version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-select': specifier: ^2.1.6 version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -808,6 +811,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + 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 + '@radix-ui/react-roving-focus@1.1.11': resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} peerDependencies: @@ -3557,6 +3573,24 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 146f433..c959b75 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -67,6 +67,7 @@ model Preferences { showOnlyUnread Boolean @default(false) debug Boolean @default(false) displayMode Json @default("{\"compact\": false, \"itemsPerPage\": 20}") + background Json @default("{\"type\": \"default\"}") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/components/layout/ClientLayout.tsx b/src/components/layout/ClientLayout.tsx index 06aae95..42e1b99 100644 --- a/src/components/layout/ClientLayout.tsx +++ b/src/components/layout/ClientLayout.tsx @@ -1,7 +1,7 @@ "use client"; import { ThemeProvider } from "next-themes"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Header } from "@/components/layout/Header"; import { Sidebar } from "@/components/layout/Sidebar"; import { InstallPWA } from "../ui/InstallPWA"; @@ -11,6 +11,7 @@ import { registerServiceWorker } from "@/lib/registerSW"; import { NetworkStatus } from "../ui/NetworkStatus"; import { DebugWrapper } from "@/components/debug/DebugWrapper"; import { DebugProvider } from "@/contexts/DebugContext"; +import { usePreferences } from "@/contexts/PreferencesContext"; import type { KomgaLibrary, KomgaSeries } from "@/types/komga"; // Routes qui ne nécessitent pas d'authentification @@ -26,6 +27,29 @@ interface ClientLayoutProps { export default function ClientLayout({ children, initialLibraries = [], initialFavorites = [], userIsAdmin = false }: ClientLayoutProps) { const [isSidebarOpen, setIsSidebarOpen] = useState(false); const pathname = usePathname(); + const { preferences } = usePreferences(); + + const backgroundStyle = useMemo(() => { + const bg = preferences.background; + + if (bg.type === "gradient" && bg.gradient) { + return { + background: bg.gradient, + backgroundAttachment: "fixed" as const, + }; + } + + if (bg.type === "image" && bg.imageUrl) { + return { + backgroundImage: `url(${bg.imageUrl})`, + backgroundSize: "cover" as const, + backgroundPosition: "center" as const, + backgroundAttachment: "fixed" as const, + }; + } + + return {}; + }, [preferences.background]); const handleCloseSidebar = () => { setIsSidebarOpen(false); @@ -71,7 +95,7 @@ export default function ClientLayout({ children, initialLibraries = [], initialF return ( -
+
{!isPublicRoute &&
} {!isPublicRoute && ( { + try { + await updatePreferences({ + background: { + ...preferences.background, + type, + }, + }); + toast({ + title: t("settings.title"), + description: t("settings.komga.messages.configSaved"), + }); + } catch (error) { + console.error("Erreur:", error); + toast({ + variant: "destructive", + title: t("settings.error.title"), + description: t("settings.error.message"), + }); + } + }; + + const handleGradientSelect = async (gradient: string) => { + try { + await updatePreferences({ + background: { + type: "gradient", + gradient, + }, + }); + toast({ + title: t("settings.title"), + description: t("settings.komga.messages.configSaved"), + }); + } catch (error) { + console.error("Erreur:", error); + toast({ + variant: "destructive", + title: t("settings.error.title"), + description: t("settings.error.message"), + }); + } + }; + + const handleCustomImageSave = async () => { + if (!customImageUrl.trim()) { + toast({ + variant: "destructive", + title: t("settings.error.title"), + description: "Veuillez entrer une URL valide", + }); + return; + } + + try { + await updatePreferences({ + background: { + type: "image", + imageUrl: customImageUrl, + }, + }); + toast({ + title: t("settings.title"), + description: t("settings.komga.messages.configSaved"), + }); + } catch (error) { + console.error("Erreur:", error); + toast({ + variant: "destructive", + title: t("settings.error.title"), + description: t("settings.error.message"), + }); + } + }; + + return ( +
+
+
+

+ {t("settings.background.title")} +

+

+ {t("settings.background.description")} +

+
+ +
+ {/* Type de background */} +
+ + handleBackgroundTypeChange(value as BackgroundType)} + className="space-y-2" + > +
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Sélection de dégradé */} + {preferences.background.type === "gradient" && ( +
+ +
+ {GRADIENT_PRESETS.map((preset) => ( + + ))} +
+
+ )} + + {/* URL d'image personnalisée */} + {preferences.background.type === "image" && ( +
+ +
+ setCustomImageUrl(e.target.value)} + className="flex-1" + /> + +
+

+ {t("settings.background.image.description")} +

+
+ )} +
+
+
+ ); +} + diff --git a/src/components/settings/ClientSettings.tsx b/src/components/settings/ClientSettings.tsx index 89a0624..735f3c3 100644 --- a/src/components/settings/ClientSettings.tsx +++ b/src/components/settings/ClientSettings.tsx @@ -5,6 +5,7 @@ import { useTranslate } from "@/hooks/useTranslate"; import { DisplaySettings } from "./DisplaySettings"; import { KomgaSettings } from "./KomgaSettings"; import { CacheSettings } from "./CacheSettings"; +import { BackgroundSettings } from "./BackgroundSettings"; interface ClientSettingsProps { initialConfig: KomgaConfig | null; @@ -19,6 +20,7 @@ export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettin

{t("settings.title")}

+
diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..8771b92 --- /dev/null +++ b/src/components/ui/radio-group.tsx @@ -0,0 +1,41 @@ +"use client"; + +import * as React from "react"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { Circle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { RadioGroup, RadioGroupItem }; + diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json index 1c7d378..c1346b8 100644 --- a/src/i18n/messages/en/common.json +++ b/src/i18n/messages/en/common.json @@ -83,6 +83,24 @@ "description": "Show debug information in the interface" } }, + "background": { + "title": "Background", + "description": "Customize your application background.", + "type": { + "label": "Background type", + "default": "Default", + "gradient": "Gradient", + "image": "Custom image" + }, + "gradient": { + "label": "Choose a gradient" + }, + "image": { + "label": "Image URL", + "description": "Enter an image URL (HTTPS recommended)", + "save": "Save" + } + }, "error": { "title": "Error", "message": "An error occurred while updating preferences" diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json index 6fa7adf..97054cf 100644 --- a/src/i18n/messages/fr/common.json +++ b/src/i18n/messages/fr/common.json @@ -83,6 +83,24 @@ "description": "Afficher les informations de debug dans l'interface" } }, + "background": { + "title": "Arrière-plan", + "description": "Personnalisez l'arrière-plan de votre application.", + "type": { + "label": "Type d'arrière-plan", + "default": "Par défaut", + "gradient": "Dégradé", + "image": "Image personnalisée" + }, + "gradient": { + "label": "Choisir un dégradé" + }, + "image": { + "label": "URL de l'image", + "description": "Entrez l'URL d'une image (HTTPS recommandé)", + "save": "Enregistrer" + } + }, "error": { "title": "Erreur", "message": "Une erreur est survenue lors de la mise à jour des préférences" diff --git a/src/lib/services/preferences.service.ts b/src/lib/services/preferences.service.ts index 5e5ce42..d52733b 100644 --- a/src/lib/services/preferences.service.ts +++ b/src/lib/services/preferences.service.ts @@ -2,9 +2,10 @@ import prisma from "@/lib/prisma"; import { getCurrentUser } from "../auth-utils"; import { ERROR_CODES } from "../../constants/errorCodes"; import { AppError } from "../../utils/errors"; -import type { UserPreferences } from "@/types/preferences"; +import type { UserPreferences, BackgroundPreferences } from "@/types/preferences"; import { defaultPreferences } from "@/types/preferences"; import type { User } from "@/types/komga"; +import type { Prisma } from "@prisma/client"; export class PreferencesService { static async getCurrentUser(): Promise { @@ -32,6 +33,7 @@ export class PreferencesService { showOnlyUnread: preferences.showOnlyUnread, debug: preferences.debug, displayMode: preferences.displayMode as UserPreferences["displayMode"], + background: (preferences.background as Prisma.JsonValue) as BackgroundPreferences, }; } catch (error) { if (error instanceof AppError) { @@ -51,6 +53,7 @@ export class PreferencesService { if (preferences.showOnlyUnread !== undefined) updateData.showOnlyUnread = preferences.showOnlyUnread; if (preferences.debug !== undefined) updateData.debug = preferences.debug; if (preferences.displayMode !== undefined) updateData.displayMode = preferences.displayMode; + if (preferences.background !== undefined) updateData.background = preferences.background; const updatedPreferences = await prisma.preferences.upsert({ where: { userId: user.id }, @@ -62,6 +65,7 @@ export class PreferencesService { showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread, debug: preferences.debug ?? defaultPreferences.debug, displayMode: preferences.displayMode ?? defaultPreferences.displayMode, + background: (preferences.background ?? defaultPreferences.background) as Prisma.InputJsonValue, }, }); @@ -71,6 +75,7 @@ export class PreferencesService { showOnlyUnread: updatedPreferences.showOnlyUnread, debug: updatedPreferences.debug, displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"], + background: (updatedPreferences.background as Prisma.JsonValue) as BackgroundPreferences, }; } catch (error) { if (error instanceof AppError) { diff --git a/src/types/preferences.ts b/src/types/preferences.ts index bf7a500..265a4eb 100644 --- a/src/types/preferences.ts +++ b/src/types/preferences.ts @@ -1,3 +1,11 @@ +export type BackgroundType = "default" | "gradient" | "image"; + +export interface BackgroundPreferences { + type: BackgroundType; + gradient?: string; + imageUrl?: string; +} + export interface UserPreferences { showThumbnails: boolean; cacheMode: "memory" | "file"; @@ -7,6 +15,7 @@ export interface UserPreferences { compact: boolean; itemsPerPage: number; }; + background: BackgroundPreferences; } export const defaultPreferences: UserPreferences = { @@ -18,4 +27,51 @@ export const defaultPreferences: UserPreferences = { compact: false, itemsPerPage: 20, }, + background: { + type: "default", + }, }; + +// Dégradés prédéfinis +export const GRADIENT_PRESETS = [ + { + id: "indigo-purple", + name: "Indigo Purple", + gradient: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", + }, + { + id: "blue-teal", + name: "Blue Teal", + gradient: "linear-gradient(135deg, #0093E9 0%, #80D0C7 100%)", + }, + { + id: "pink-orange", + name: "Pink Orange", + gradient: "linear-gradient(135deg, #FF6B6B 0%, #FFE66D 100%)", + }, + { + id: "purple-pink", + name: "Purple Pink", + gradient: "linear-gradient(135deg, #A8EDEA 0%, #FED6E3 100%)", + }, + { + id: "dark-blue", + name: "Dark Blue", + gradient: "linear-gradient(135deg, #0F2027 0%, #203A43 50%, #2C5364 100%)", + }, + { + id: "sunset", + name: "Sunset", + gradient: "linear-gradient(135deg, #FF512F 0%, #DD2476 100%)", + }, + { + id: "ocean", + name: "Ocean", + gradient: "linear-gradient(135deg, #2E3192 0%, #1BFFFF 100%)", + }, + { + id: "forest", + name: "Forest", + gradient: "linear-gradient(135deg, #134E5E 0%, #71B280 100%)", + }, +] as const;