feat: introduce customizable background options with new gradient and solid color styles; integrate BackgroundProvider and BackgroundCard components for enhanced user experience in settings and layout
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m52s

This commit is contained in:
Julien Froidefond
2025-12-21 13:43:16 +01:00
parent 2452e30a0f
commit 6c14484636
7 changed files with 516 additions and 13 deletions

View File

@@ -375,6 +375,54 @@
overflow: hidden;
}
/* Fonds personnalisables */
.page-background.bg-default {
background: linear-gradient(
135deg,
oklch(0.98 0.01 280) 0%,
oklch(0.97 0.012 270) 50%,
oklch(0.98 0.01 290) 100%
);
}
.page-background.bg-gradient-blue {
background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 50%, #7dd3fc 100%);
}
.page-background.bg-gradient-purple {
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 50%, #d8b4fe 100%);
}
.page-background.bg-gradient-green {
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 50%, #86efac 100%);
}
.page-background.bg-gradient-orange {
background: linear-gradient(135deg, #fff7ed 0%, #ffedd5 50%, #fed7aa 100%);
}
.page-background.bg-solid-light {
background: #ffffff;
}
.page-background.bg-solid-dark {
background: #0f172a !important;
}
.page-background.bg-custom-image {
background-image: var(--custom-background-image);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
background-attachment: fixed;
}
.page-background.bg-custom-image::before,
.page-background.bg-solid-light::before,
.page-background.bg-solid-dark::before {
display: none;
}
.page-background::before {
content: "";
position: fixed;
@@ -415,7 +463,7 @@
opacity: 1;
}
.dark .page-background {
.dark .page-background.bg-default {
background: linear-gradient(
135deg,
oklch(0.1 0.015 280) 0%,
@@ -424,6 +472,30 @@
);
}
.dark .page-background.bg-gradient-blue {
background: linear-gradient(135deg, #0c4a6e 0%, #075985 50%, #0369a1 100%);
}
.dark .page-background.bg-gradient-purple {
background: linear-gradient(135deg, #581c87 0%, #6b21a8 50%, #7c3aed 100%);
}
.dark .page-background.bg-gradient-green {
background: linear-gradient(135deg, #14532d 0%, #166534 50%, #16a34a 100%);
}
.dark .page-background.bg-gradient-orange {
background: linear-gradient(135deg, #7c2d12 0%, #9a3412 50%, #c2410c 100%);
}
.dark .page-background.bg-solid-light {
background: #f8fafc;
}
.dark .page-background.bg-solid-dark {
background: #0f172a !important;
}
.dark .page-background::before {
background:
radial-gradient(
@@ -481,8 +553,11 @@
content: "";
position: absolute;
inset: 0;
background-image:
radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--foreground) 2%, transparent) 1px, transparent 0);
background-image: radial-gradient(
circle at 1px 1px,
color-mix(in srgb, var(--foreground) 2%, transparent) 1px,
transparent 0
);
background-size: 20px 20px;
opacity: 0.15;
pointer-events: none;
@@ -490,25 +565,46 @@
}
.dark .fintech-card::before {
background-image:
radial-gradient(circle at 1px 1px, color-mix(in srgb, white 3%, transparent) 1px, transparent 0);
background-image: radial-gradient(
circle at 1px 1px,
color-mix(in srgb, white 3%, transparent) 1px,
transparent 0
);
opacity: 0.1;
}
/* Texture plus prononcée pour les cards de statistiques */
.stat-card-textured::before {
background-image:
radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--foreground) 3%, transparent) 1px, transparent 0),
radial-gradient(circle at 11px 11px, color-mix(in srgb, var(--foreground) 1.5%, transparent) 1px, transparent 0);
background-size: 16px 16px, 24px 24px;
background-image:
radial-gradient(
circle at 1px 1px,
color-mix(in srgb, var(--foreground) 3%, transparent) 1px,
transparent 0
),
radial-gradient(
circle at 11px 11px,
color-mix(in srgb, var(--foreground) 1.5%, transparent) 1px,
transparent 0
);
background-size:
16px 16px,
24px 24px;
opacity: 0.2;
z-index: 1;
}
.dark .stat-card-textured::before {
background-image:
radial-gradient(circle at 1px 1px, color-mix(in srgb, white 4%, transparent) 1px, transparent 0),
radial-gradient(circle at 11px 11px, color-mix(in srgb, white 2%, transparent) 1px, transparent 0);
background-image:
radial-gradient(
circle at 1px 1px,
color-mix(in srgb, white 4%, transparent) 1px,
transparent 0
),
radial-gradient(
circle at 11px 11px,
color-mix(in srgb, white 2%, transparent) 1px,
transparent 0
);
opacity: 0.15;
}

View File

@@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthSessionProvider } from "@/components/providers/session-provider";
import { QueryProvider } from "@/components/providers/query-provider";
import { BackgroundProvider } from "@/components/providers/background-provider";
const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] });
@@ -23,6 +24,7 @@ export default function RootLayout({
return (
<html lang="fr">
<body className="font-sans antialiased">
<BackgroundProvider />
<QueryProvider>
<AuthSessionProvider>{children}</AuthSessionProvider>
</QueryProvider>

View File

@@ -9,6 +9,7 @@ import {
BackupCard,
PasswordCard,
ReconcileDateRangeCard,
BackgroundCard,
} from "@/components/settings";
import { useBankingData } from "@/lib/hooks";
import type { BankingData } from "@/lib/types";
@@ -126,6 +127,8 @@ export default function SettingsPage() {
<PasswordCard />
<BackgroundCard />
<ReconcileDateRangeCard />
<DangerZoneCard

View File

@@ -15,7 +15,7 @@ export function PageLayout({ children }: PageLayoutProps) {
<SidebarContext.Provider
value={{ open: sidebarOpen, setOpen: setSidebarOpen }}
>
<div className="flex h-screen bg-background overflow-hidden page-background">
<div className="flex h-screen bg-background overflow-hidden page-background bg-default">
<Sidebar open={sidebarOpen} onOpenChange={setSidebarOpen} />
<main className="flex-1 overflow-auto overflow-x-hidden page-content">
<div className="p-3 md:p-8 lg:p-10 space-y-4 md:space-y-8 max-w-full">

View File

@@ -0,0 +1,89 @@
"use client";
import { useEffect } from "react";
interface BackgroundSettings {
type: string;
customImageUrl?: string;
}
export function BackgroundProvider() {
useEffect(() => {
const applyBackground = () => {
try {
const pageBackground = document.querySelector(
".page-background"
) as HTMLElement;
if (!pageBackground) return;
const stored = localStorage.getItem("background-settings");
const settings: BackgroundSettings = stored
? JSON.parse(stored)
: { type: "default" };
// Retirer toutes les classes de fond
pageBackground.classList.remove(
"bg-default",
"bg-gradient-blue",
"bg-gradient-purple",
"bg-gradient-green",
"bg-gradient-orange",
"bg-solid-light",
"bg-solid-dark",
"bg-custom-image"
);
const root = document.documentElement;
if (settings.type === "custom-image" && settings.customImageUrl) {
pageBackground.classList.add("bg-custom-image");
root.style.setProperty(
"--custom-background-image",
`url(${settings.customImageUrl})`
);
} else {
pageBackground.classList.add(`bg-${settings.type || "default"}`);
root.style.removeProperty("--custom-background-image");
}
} catch (error) {
console.error("Error applying background:", error);
}
};
// Appliquer immédiatement
applyBackground();
// Observer pour les changements de DOM (si le page-background est ajouté plus tard)
const observer = new MutationObserver(() => {
applyBackground();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
// Écouter les changements dans localStorage (autres onglets)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === "background-settings") {
applyBackground();
}
};
// Écouter les événements personnalisés (même onglet)
const handleBackgroundChanged = () => {
applyBackground();
};
window.addEventListener("storage", handleStorageChange);
window.addEventListener("background-changed", handleBackgroundChanged);
return () => {
observer.disconnect();
window.removeEventListener("storage", handleStorageChange);
window.removeEventListener("background-changed", handleBackgroundChanged);
};
}, []);
return null;
}

View File

@@ -0,0 +1,312 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Image, X } from "lucide-react";
import { useLocalStorage } from "@/hooks/use-local-storage";
import { cn } from "@/lib/utils";
type BackgroundType =
| "default"
| "gradient-blue"
| "gradient-purple"
| "gradient-green"
| "gradient-orange"
| "solid-light"
| "solid-dark"
| "custom-image";
interface BackgroundSettings {
type: BackgroundType;
customImageUrl?: string;
}
const DEFAULT_BACKGROUNDS: Array<{
value: BackgroundType;
label: string;
preview: string;
}> = [
{
value: "default",
label: "Par défaut",
preview:
"linear-gradient(135deg, oklch(0.98 0.01 280) 0%, oklch(0.97 0.012 270) 50%, oklch(0.98 0.01 290) 100%)",
},
{
value: "gradient-blue",
label: "Dégradé bleu",
preview: "linear-gradient(135deg, #e0f2fe 0%, #bae6fd 50%, #7dd3fc 100%)",
},
{
value: "gradient-purple",
label: "Dégradé violet",
preview: "linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 50%, #d8b4fe 100%)",
},
{
value: "gradient-green",
label: "Dégradé vert",
preview: "linear-gradient(135deg, #dcfce7 0%, #bbf7d0 50%, #86efac 100%)",
},
{
value: "gradient-orange",
label: "Dégradé orange",
preview: "linear-gradient(135deg, #fff7ed 0%, #ffedd5 50%, #fed7aa 100%)",
},
{
value: "solid-light",
label: "Solide clair",
preview: "#ffffff",
},
{
value: "solid-dark",
label: "Solide sombre",
preview: "#1e293b",
},
];
export function BackgroundCard() {
const [backgroundSettings, setBackgroundSettings] =
useLocalStorage<BackgroundSettings>("background-settings", {
type: "default",
});
const currentSettings = useMemo<BackgroundSettings>(
() => backgroundSettings || { type: "default" },
[backgroundSettings]
);
const [customImageUrl, setCustomImageUrl] = useState(
currentSettings.customImageUrl || ""
);
const [showCustomInput, setShowCustomInput] = useState(
currentSettings.type === "custom-image"
);
// Synchroniser customImageUrl avec les settings
useEffect(() => {
if (
currentSettings.type === "custom-image" &&
currentSettings.customImageUrl
) {
setCustomImageUrl(currentSettings.customImageUrl);
}
}, [currentSettings]);
const applyBackground = (settings: BackgroundSettings) => {
const root = document.documentElement;
const pageBackground = document.querySelector(
".page-background"
) as HTMLElement;
if (!pageBackground) return;
// Retirer toutes les classes de fond
pageBackground.classList.remove(
"bg-default",
"bg-gradient-blue",
"bg-gradient-purple",
"bg-gradient-green",
"bg-gradient-orange",
"bg-solid-light",
"bg-solid-dark",
"bg-custom-image"
);
if (settings.type === "custom-image" && settings.customImageUrl) {
pageBackground.classList.add("bg-custom-image");
root.style.setProperty(
"--custom-background-image",
`url(${settings.customImageUrl})`
);
} else {
pageBackground.classList.add(`bg-${settings.type || "default"}`);
root.style.removeProperty("--custom-background-image");
}
// Déclencher un événement personnalisé pour notifier les autres composants
window.dispatchEvent(
new CustomEvent("background-changed", { detail: settings })
);
};
const handleBackgroundChange = (type: BackgroundType) => {
if (type === "custom-image") {
setShowCustomInput(true);
if (customImageUrl.trim()) {
const newSettings: BackgroundSettings = {
type,
customImageUrl: customImageUrl.trim(),
};
setBackgroundSettings(newSettings);
applyBackground(newSettings);
} else {
const newSettings: BackgroundSettings = { type };
setBackgroundSettings(newSettings);
}
} else {
setShowCustomInput(false);
const newSettings: BackgroundSettings = { type };
setBackgroundSettings(newSettings);
applyBackground(newSettings);
}
};
const handleCustomImageSubmit = () => {
if (customImageUrl.trim()) {
const newSettings: BackgroundSettings = {
type: "custom-image",
customImageUrl: customImageUrl.trim(),
};
setBackgroundSettings(newSettings);
applyBackground(newSettings);
}
};
const handleRemoveCustomImage = () => {
setCustomImageUrl("");
setShowCustomInput(false);
const newSettings: BackgroundSettings = { type: "default" };
setBackgroundSettings(newSettings);
applyBackground(newSettings);
};
// Appliquer le fond au chargement et quand il change
useEffect(() => {
if (typeof window !== "undefined") {
applyBackground(currentSettings);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSettings.type, currentSettings.customImageUrl]);
return (
<Card className="card-hover">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Image className="w-5 h-5" />
Fond du site
</CardTitle>
<CardDescription>
Personnalisez l'apparence du fond de l'application
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<RadioGroup
value={currentSettings.type}
onValueChange={(value) =>
handleBackgroundChange(value as BackgroundType)
}
>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{DEFAULT_BACKGROUNDS.map((bg) => (
<label
key={bg.value}
htmlFor={`bg-${bg.value}`}
className={cn(
"relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all",
currentSettings.type === bg.value
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
)}
>
<RadioGroupItem
value={bg.value}
id={`bg-${bg.value}`}
className="sr-only"
/>
<div
className="w-full h-16 rounded-md mb-2 border border-border/50"
style={{ background: bg.preview }}
/>
<span className="text-xs font-medium text-center">
{bg.label}
</span>
</label>
))}
<label
htmlFor="bg-custom-image"
className={cn(
"relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all",
currentSettings.type === "custom-image"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
)}
>
<RadioGroupItem
value="custom-image"
id="bg-custom-image"
className="sr-only"
/>
<div className="w-full h-16 rounded-md mb-2 border border-border/50 bg-muted flex items-center justify-center">
<Image className="w-6 h-6 text-muted-foreground" />
</div>
<span className="text-xs font-medium text-center">
Image personnalisée
</span>
</label>
</div>
</RadioGroup>
{showCustomInput && (
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<div className="space-y-2">
<Label htmlFor="custom-image-url">URL de l'image</Label>
<div className="flex gap-2">
<Input
id="custom-image-url"
type="url"
placeholder="https://example.com/image.jpg"
value={customImageUrl}
onChange={(e) => setCustomImageUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCustomImageSubmit();
}
}}
/>
{customImageUrl && (
<Button
variant="outline"
size="icon"
onClick={handleRemoveCustomImage}
>
<X className="w-4 h-4" />
</Button>
)}
</div>
</div>
<Button
onClick={handleCustomImageSubmit}
disabled={!customImageUrl.trim()}
>
Appliquer l'image
</Button>
{currentSettings.type === "custom-image" &&
currentSettings.customImageUrl && (
<div className="mt-3">
<div className="text-xs text-muted-foreground mb-2">
Aperçu :
</div>
<div
className="w-full h-32 rounded-md border border-border bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url(${currentSettings.customImageUrl})`,
}}
/>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -4,3 +4,4 @@ export { OFXInfoCard } from "./ofx-info-card";
export { BackupCard } from "./backup-card";
export { PasswordCard } from "./password-card";
export { ReconcileDateRangeCard } from "./reconcile-date-range-card";
export { BackgroundCard } from "./background-card";