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

@@ -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";