feat: implement personalized background image feature
- Added functionality for users to select and customize background images in settings, including predefined options and URL uploads. - Updated `ViewPreferences` to store background image settings and modified `userPreferencesService` to handle updates. - Enhanced global styles for improved readability with background images, including blur and transparency effects. - Integrated `BackgroundImageSelector` component into settings for intuitive user experience. - Refactored `Card` components across the app to use a new 'glass' variant for better aesthetics.
This commit is contained in:
24
TODO.md
24
TODO.md
@@ -2,12 +2,34 @@
|
|||||||
|
|
||||||
## Idées à developper
|
## Idées à developper
|
||||||
- [x] Refacto et intégration design : mode sombre et clair sont souvent mal généré par défaut <!-- Diagnostic terminé -->
|
- [x] Refacto et intégration design : mode sombre et clair sont souvent mal généré par défaut <!-- Diagnostic terminé -->
|
||||||
- [ ] Personnalisation : couleurs
|
- [x] Personnalisation : couleurs <!-- Image de fond personnalisée implémentée -->
|
||||||
- [ ] Optimisations Perf : requetes DB
|
- [ ] Optimisations Perf : requetes DB
|
||||||
- [ ] PWA et mode offline
|
- [ ] PWA et mode offline
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🖼️ **IMAGE DE FOND PERSONNALISÉE** ✅ TERMINÉ
|
||||||
|
|
||||||
|
### **Fonctionnalités implémentées :**
|
||||||
|
- [x] **Sélecteur d'images de fond** dans les paramètres généraux
|
||||||
|
- [x] **Images prédéfinies** : dégradés bleu, violet, coucher de soleil, océan, forêt
|
||||||
|
- [x] **URL personnalisée** : possibilité d'ajouter une image via URL
|
||||||
|
- [x] **Aperçu en temps réel** de l'image sélectionnée
|
||||||
|
- [x] **Application globale** : l'image s'applique sur toutes les pages
|
||||||
|
- [x] **Optimisation visuelle** : effet de flou et transparence pour la lisibilité
|
||||||
|
- [x] **Sauvegarde persistante** : préférence sauvegardée en base de données
|
||||||
|
- [x] **Interface intuitive** : sélection facile avec aperçus visuels
|
||||||
|
|
||||||
|
### **Architecture technique :**
|
||||||
|
- **Types** : `backgroundImage` ajouté à `ViewPreferences`
|
||||||
|
- **Service** : `userPreferencesService` mis à jour
|
||||||
|
- **Actions** : `setBackgroundImage` server action créée
|
||||||
|
- **Composant** : `BackgroundImageSelector` avec presets et URL personnalisée
|
||||||
|
- **Contexte** : `BackgroundContext` pour l'application globale
|
||||||
|
- **Styles** : CSS optimisé pour la lisibilité avec images de fond
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🎨 **REFACTORING THÈME & PERSONNALISATION COULEURS**
|
## 🎨 **REFACTORING THÈME & PERSONNALISATION COULEURS**
|
||||||
|
|
||||||
### **Phase 1: Nettoyage Architecture Thème**
|
### **Phase 1: Nettoyage Architecture Thème**
|
||||||
|
|||||||
@@ -32,6 +32,31 @@ export async function updateViewPreferences(updates: Partial<ViewPreferences>):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour l'image de fond
|
||||||
|
*/
|
||||||
|
export async function setBackgroundImage(backgroundImage: string | undefined): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'Non authentifié' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await userPreferencesService.updateViewPreferences(session.user.id, { backgroundImage });
|
||||||
|
revalidatePath('/');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur setBackgroundImage:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Met à jour les filtres Kanban
|
* Met à jour les filtres Kanban
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -424,10 +424,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background-color: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: var(--font-geist-mono), 'Courier New', monospace;
|
font-family: var(--font-geist-mono), 'Courier New', monospace;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
transition: background-image 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles pour les images de fond */
|
||||||
|
body.has-background-image {
|
||||||
|
/* Assurer que le contenu reste lisible avec une image de fond */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.has-background-image::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Améliorer la lisibilité des cartes avec image de fond */
|
||||||
|
body.has-background-image .bg-\[var\(--card\)\] {
|
||||||
|
background-color: color-mix(in srgb, var(--card) 90%, transparent) !important;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.has-background-image .bg-\[var\(--card\)\]\/30 {
|
||||||
|
background-color: color-mix(in srgb, var(--card) 20%, transparent) !important;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rendre les conteneurs principaux transparents avec image de fond */
|
||||||
|
body.has-background-image .min-h-screen.bg-\[var\(--background\)\] {
|
||||||
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar tech style */
|
/* Scrollbar tech style */
|
||||||
@@ -552,3 +587,35 @@ body {
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Styles pour les sliders */
|
||||||
|
.slider::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid var(--background);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-webkit-slider-thumb:hover {
|
||||||
|
background: color-mix(in srgb, var(--primary) 80%, var(--accent) 20%);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid var(--background);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb:hover {
|
||||||
|
background: color-mix(in srgb, var(--primary) 80%, var(--accent) 20%);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||||
|
import { BackgroundProvider } from "@/contexts/BackgroundContext";
|
||||||
import { JiraConfigProvider } from "@/contexts/JiraConfigContext";
|
import { JiraConfigProvider } from "@/contexts/JiraConfigContext";
|
||||||
import { UserPreferencesProvider } from "@/contexts/UserPreferencesContext";
|
import { UserPreferencesProvider } from "@/contexts/UserPreferencesContext";
|
||||||
import { KeyboardShortcutsProvider } from "@/contexts/KeyboardShortcutsContext";
|
import { KeyboardShortcutsProvider } from "@/contexts/KeyboardShortcutsContext";
|
||||||
@@ -54,7 +55,9 @@ export default async function RootLayout({
|
|||||||
<KeyboardShortcuts />
|
<KeyboardShortcuts />
|
||||||
<JiraConfigProvider config={initialPreferences?.jiraConfig || { enabled: false }}>
|
<JiraConfigProvider config={initialPreferences?.jiraConfig || { enabled: false }}>
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||||
|
<BackgroundProvider>
|
||||||
{children}
|
{children}
|
||||||
|
</BackgroundProvider>
|
||||||
</UserPreferencesProvider>
|
</UserPreferencesProvider>
|
||||||
</JiraConfigProvider>
|
</JiraConfigProvider>
|
||||||
</KeyboardShortcutsProvider>
|
</KeyboardShortcutsProvider>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function DailySection({
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
id={`daily-dnd-${title.replace(/[^a-zA-Z0-9]/g, '-')}`}
|
id={`daily-dnd-${title.replace(/[^a-zA-Z0-9]/g, '-')}`}
|
||||||
>
|
>
|
||||||
<Card className="p-0 flex flex-col h-[80vh] sm:h-[600px]">
|
<Card variant="glass" className="p-0 flex flex-col h-[80vh] sm:h-[600px]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 pb-0">
|
<div className="p-4 pb-0">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function PendingTasksSection({
|
|||||||
const pendingCount = pendingTasks.length;
|
const pendingCount = pendingTasks.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mt-6">
|
<Card variant="glass" className="mt-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
|
|||||||
349
src/components/settings/BackgroundImageSelector.tsx
Normal file
349
src/components/settings/BackgroundImageSelector.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||||
|
|
||||||
|
// Images de fond prédéfinies basées sur le thème actuel
|
||||||
|
const PRESET_BACKGROUNDS = [
|
||||||
|
{
|
||||||
|
id: 'none',
|
||||||
|
name: 'Aucune',
|
||||||
|
description: 'Fond uni par défaut',
|
||||||
|
preview: 'linear-gradient(135deg, var(--background) 0%, var(--card) 100%)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-subtle',
|
||||||
|
name: 'Dégradé subtil',
|
||||||
|
description: 'Dégradé doux avec les couleurs du thème',
|
||||||
|
preview: 'linear-gradient(135deg, var(--background) 0%, var(--card-column) 100%)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-primary',
|
||||||
|
name: 'Dégradé primaire',
|
||||||
|
description: 'Dégradé avec la couleur primaire du thème',
|
||||||
|
preview: 'linear-gradient(135deg, var(--background) 0%, color-mix(in srgb, var(--primary) 20%, var(--background)) 100%)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-accent',
|
||||||
|
name: 'Dégradé accent',
|
||||||
|
description: 'Dégradé avec la couleur d\'accent du thème',
|
||||||
|
preview: 'linear-gradient(135deg, var(--background) 0%, color-mix(in srgb, var(--accent) 20%, var(--background)) 100%)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-success',
|
||||||
|
name: 'Dégradé succès',
|
||||||
|
description: 'Dégradé avec la couleur de succès du thème',
|
||||||
|
preview: 'linear-gradient(135deg, var(--background) 0%, color-mix(in srgb, var(--success) 20%, var(--background)) 100%)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-purple',
|
||||||
|
name: 'Dégradé violet',
|
||||||
|
description: 'Dégradé avec la couleur violette du thème',
|
||||||
|
preview: 'linear-gradient(135deg, var(--background) 0%, color-mix(in srgb, var(--purple) 20%, var(--background)) 100%)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-diagonal',
|
||||||
|
name: 'Dégradé diagonal',
|
||||||
|
description: 'Dégradé diagonal avec plusieurs couleurs du thème',
|
||||||
|
preview: 'linear-gradient(45deg, var(--background) 0%, color-mix(in srgb, var(--primary) 15%, var(--background)) 50%, color-mix(in srgb, var(--accent) 15%, var(--background)) 100%)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-radial',
|
||||||
|
name: 'Dégradé radial',
|
||||||
|
description: 'Dégradé radial centré avec les couleurs du thème',
|
||||||
|
preview: 'radial-gradient(circle at center, var(--background) 0%, color-mix(in srgb, var(--primary) 25%, var(--background)) 100%)'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BackgroundImageSelector() {
|
||||||
|
const { preferences, updateViewPreferences } = useUserPreferences();
|
||||||
|
const [customUrl, setCustomUrl] = useState('');
|
||||||
|
const [showCustomInput, setShowCustomInput] = useState(false);
|
||||||
|
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||||
|
|
||||||
|
const currentBackground = preferences?.viewPreferences?.backgroundImage;
|
||||||
|
const backgroundBlur = preferences?.viewPreferences?.backgroundBlur || 0;
|
||||||
|
const backgroundOpacity = preferences?.viewPreferences?.backgroundOpacity || 100;
|
||||||
|
|
||||||
|
const handlePresetSelect = (presetId: string) => {
|
||||||
|
const backgroundImage = presetId === 'none' ? undefined : presetId;
|
||||||
|
updateViewPreferences({ backgroundImage });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomUrlSubmit = () => {
|
||||||
|
if (!customUrl.trim()) return;
|
||||||
|
|
||||||
|
updateViewPreferences({ backgroundImage: customUrl.trim() });
|
||||||
|
setCustomUrl('');
|
||||||
|
setShowCustomInput(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCustom = () => {
|
||||||
|
updateViewPreferences({ backgroundImage: undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlurChange = (blur: number) => {
|
||||||
|
updateViewPreferences({ backgroundBlur: blur });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpacityChange = (opacity: number) => {
|
||||||
|
updateViewPreferences({ backgroundOpacity: opacity });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-mono font-semibold text-[var(--foreground)]">Image de fond</h3>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||||
|
Personnalisez l'arrière-plan de toutes les pages
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{currentBackground ? (
|
||||||
|
<span className="font-medium text-[var(--primary)]">
|
||||||
|
{PRESET_BACKGROUNDS.find(p => p.id === currentBackground)?.name || 'Personnalisé'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium text-[var(--muted-foreground)]">Aucune</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aperçu actuel */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)] mb-3">Aperçu actuel :</div>
|
||||||
|
<div
|
||||||
|
className="w-full h-24 rounded-lg border border-[var(--border)]/30 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
backgroundImage: currentBackground && !PRESET_BACKGROUNDS.find(p => p.id === currentBackground)
|
||||||
|
? `url(${currentBackground})`
|
||||||
|
: PRESET_BACKGROUNDS.find(p => p.id === currentBackground)?.preview || 'var(--background)',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Images prédéfinies */}
|
||||||
|
<Card variant="glass">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)] mb-4">Images prédéfinies :</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
{PRESET_BACKGROUNDS.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.id}
|
||||||
|
onClick={() => handlePresetSelect(preset.id)}
|
||||||
|
variant={currentBackground === preset.id ? 'selected' : 'secondary'}
|
||||||
|
className="p-3 h-auto text-left justify-start"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Aperçu */}
|
||||||
|
<div
|
||||||
|
className="w-12 h-8 rounded border border-[var(--border)]/30 flex-shrink-0"
|
||||||
|
style={{ background: preset.preview }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium text-[var(--foreground)] mb-1">
|
||||||
|
{preset.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)] leading-relaxed">
|
||||||
|
{preset.description}
|
||||||
|
</div>
|
||||||
|
{currentBackground === preset.id && (
|
||||||
|
<div className="mt-1 text-xs text-[var(--primary)] font-medium">
|
||||||
|
✓ Sélectionné
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* URL personnalisée */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-[var(--foreground)]">URL personnalisée</h4>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||||
|
Ajoutez votre propre image de fond
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowCustomInput(!showCustomInput)}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{showCustomInput ? 'Masquer' : 'Ajouter'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCustomInput && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
value={customUrl}
|
||||||
|
onChange={(e) => setCustomUrl(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleCustomUrlSubmit}
|
||||||
|
disabled={!customUrl.trim()}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
|
Appliquer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
Entrez l'URL d'une image (JPG, PNG, GIF, WebP)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bouton pour supprimer l'image personnalisée */}
|
||||||
|
{currentBackground && !PRESET_BACKGROUNDS.find(p => p.id === currentBackground) && (
|
||||||
|
<Button
|
||||||
|
onClick={handleRemoveCustom}
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Supprimer l'image personnalisée
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Options avancées */}
|
||||||
|
{currentBackground && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-[var(--foreground)]">Options avancées</h4>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||||
|
Personnalisez l'effet de l'image de fond
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{showAdvancedOptions ? 'Masquer' : 'Afficher'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAdvancedOptions && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Contrôle du blur */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm text-[var(--foreground)]">
|
||||||
|
Flou d'arrière-plan
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{backgroundBlur}px
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="20"
|
||||||
|
step="1"
|
||||||
|
value={backgroundBlur}
|
||||||
|
onChange={(e) => handleBlurChange(Number(e.target.value))}
|
||||||
|
className="w-full h-2 bg-[var(--border)] rounded-lg appearance-none cursor-pointer slider"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-[var(--muted-foreground)]">
|
||||||
|
<span>Aucun</span>
|
||||||
|
<span>Très flou</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contrôle de l'opacité */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm text-[var(--foreground)]">
|
||||||
|
Opacité de l'image
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{backgroundOpacity}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="10"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
value={backgroundOpacity}
|
||||||
|
onChange={(e) => handleOpacityChange(Number(e.target.value))}
|
||||||
|
className="w-full h-2 bg-[var(--border)] rounded-lg appearance-none cursor-pointer slider"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-[var(--muted-foreground)]">
|
||||||
|
<span>Très transparent</span>
|
||||||
|
<span>Opaque</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aperçu en temps réel */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm text-[var(--foreground)]">
|
||||||
|
Aperçu avec les effets appliqués :
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="w-full h-16 rounded-lg border border-[var(--border)]/30 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
backgroundImage: currentBackground && !PRESET_BACKGROUNDS.find(p => p.id === currentBackground)
|
||||||
|
? `url(${currentBackground})`
|
||||||
|
: PRESET_BACKGROUNDS.find(p => p.id === currentBackground)?.preview || 'var(--background)',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
filter: `blur(${backgroundBlur}px)`,
|
||||||
|
opacity: backgroundOpacity / 100
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Note sur les performances */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="text-lg">💡</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[var(--warning)] font-medium mb-1">
|
||||||
|
Conseil pour les performances
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
Utilisez des images optimisées pour de meilleures performances.
|
||||||
|
Les images trop lourdes peuvent ralentir l'interface.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Header } from '@/components/ui/Header';
|
|||||||
import { Card, CardContent } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
import { TagsManagement } from './tags/TagsManagement';
|
import { TagsManagement } from './tags/TagsManagement';
|
||||||
import { ThemeSelector } from '@/components/ThemeSelector';
|
import { ThemeSelector } from '@/components/ThemeSelector';
|
||||||
|
import { BackgroundImageSelector } from './BackgroundImageSelector';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface GeneralSettingsPageClientProps {
|
interface GeneralSettingsPageClientProps {
|
||||||
@@ -49,13 +50,22 @@ export function GeneralSettingsPageClient({ initialTags }: GeneralSettingsPageCl
|
|||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Sélection de thème */}
|
{/* Sélection de thème */}
|
||||||
<div className="bg-[var(--card)]/30 border border-[var(--border)]/50 rounded-lg p-6 backdrop-blur-sm">
|
<Card variant="glass">
|
||||||
|
<CardContent padding="lg">
|
||||||
<ThemeSelector />
|
<ThemeSelector />
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Sélection d'image de fond */}
|
||||||
|
<Card variant="glass">
|
||||||
|
<CardContent padding="lg">
|
||||||
|
<BackgroundImageSelector />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* UI Showcase */}
|
{/* UI Showcase */}
|
||||||
<Card>
|
<Card variant="glass">
|
||||||
<CardContent className="p-6">
|
<CardContent padding="lg">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-[var(--foreground)] mb-2">
|
<h3 className="text-lg font-medium text-[var(--foreground)] mb-2">
|
||||||
@@ -83,7 +93,7 @@ export function GeneralSettingsPageClient({ initialTags }: GeneralSettingsPageCl
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Note développement futur */}
|
{/* Note développement futur */}
|
||||||
<Card>
|
<Card variant="glass">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="p-4 bg-[var(--warning)]/10 border border-[var(--warning)]/20 rounded">
|
<div className="p-4 bg-[var(--warning)]/10 border border-[var(--warning)]/20 rounded">
|
||||||
<p className="text-sm text-[var(--warning)] font-medium mb-2">
|
<p className="text-sm text-[var(--warning)] font-medium mb-2">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Tag } from '@/lib/types';
|
import { Tag } from '@/lib/types';
|
||||||
|
|
||||||
interface TagsGridProps {
|
interface TagsGridProps {
|
||||||
@@ -41,14 +42,15 @@ export function TagsGrid({
|
|||||||
const usage = tag.usage || 0;
|
const usage = tag.usage || 0;
|
||||||
const isUnused = usage === 0;
|
const isUnused = usage === 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<Card
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className={`p-3 rounded-lg border transition-all hover:shadow-sm ${
|
className={`transition-all hover:shadow-sm ${
|
||||||
isUnused
|
isUnused
|
||||||
? 'border-[var(--destructive)]/30 bg-[var(--destructive)]/5 hover:border-[var(--destructive)]/50'
|
? 'border-[var(--destructive)]/30 bg-[var(--destructive)]/5 hover:border-[var(--destructive)]/50'
|
||||||
: 'border-[var(--border)] hover:border-[var(--primary)]/50'
|
: 'hover:border-[var(--primary)]/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<div className="p-3">
|
||||||
{/* Header du tag */}
|
{/* Header du tag */}
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
@@ -120,6 +122,7 @@ export function TagsGrid({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function TagsManagement({ tags, onRefreshTags, onDeleteTag }: TagsManagem
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card variant="glass">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function Calendar({
|
|||||||
const weekDays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
|
const weekDays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`p-4 ${className}`}>
|
<Card variant="glass" className={`p-4 ${className}`}>
|
||||||
{/* Header avec navigation */}
|
{/* Header avec navigation */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { HTMLAttributes, forwardRef } from 'react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
variant?: 'default' | 'elevated' | 'bordered' | 'column';
|
variant?: 'default' | 'elevated' | 'bordered' | 'column' | 'glass';
|
||||||
shadow?: 'none' | 'sm' | 'md' | 'lg';
|
shadow?: 'none' | 'sm' | 'md' | 'lg';
|
||||||
border?: 'none' | 'default' | 'primary' | 'accent';
|
border?: 'none' | 'default' | 'primary' | 'accent';
|
||||||
background?: 'default' | 'column' | 'muted';
|
background?: 'default' | 'column' | 'muted';
|
||||||
@@ -35,7 +35,8 @@ const Card = forwardRef<HTMLDivElement, CardProps>(
|
|||||||
default: '',
|
default: '',
|
||||||
elevated: 'shadow-lg',
|
elevated: 'shadow-lg',
|
||||||
bordered: 'border-[var(--primary)]/30 shadow-lg',
|
bordered: 'border-[var(--primary)]/30 shadow-lg',
|
||||||
column: 'bg-[var(--card-column)] shadow-lg'
|
column: 'bg-[var(--card-column)] shadow-lg',
|
||||||
|
glass: 'bg-[var(--card)]/30 border border-[var(--border)]/50 backdrop-blur-sm'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Appliquer le variant si spécifié, sinon utiliser les props individuelles
|
// Appliquer le variant si spécifié, sinon utiliser les props individuelles
|
||||||
|
|||||||
134
src/contexts/BackgroundContext.tsx
Normal file
134
src/contexts/BackgroundContext.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||||
|
import { useUserPreferences } from './UserPreferencesContext';
|
||||||
|
|
||||||
|
interface BackgroundContextType {
|
||||||
|
backgroundImage: string | undefined;
|
||||||
|
setBackgroundImage: (image: string | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BackgroundContext = createContext<BackgroundContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface BackgroundProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackgroundProvider({ children }: BackgroundProviderProps) {
|
||||||
|
const { preferences } = useUserPreferences();
|
||||||
|
const [backgroundImage, setBackgroundImageState] = useState<string | undefined>(
|
||||||
|
preferences?.viewPreferences?.backgroundImage
|
||||||
|
);
|
||||||
|
const [backgroundBlur, setBackgroundBlurState] = useState<number>(
|
||||||
|
preferences?.viewPreferences?.backgroundBlur || 0
|
||||||
|
);
|
||||||
|
const [backgroundOpacity, setBackgroundOpacityState] = useState<number>(
|
||||||
|
preferences?.viewPreferences?.backgroundOpacity || 100
|
||||||
|
);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// Hydration safe initialization
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync with preferences
|
||||||
|
useEffect(() => {
|
||||||
|
if (preferences?.viewPreferences?.backgroundImage !== backgroundImage) {
|
||||||
|
setBackgroundImageState(preferences?.viewPreferences?.backgroundImage);
|
||||||
|
}
|
||||||
|
if (preferences?.viewPreferences?.backgroundBlur !== backgroundBlur) {
|
||||||
|
setBackgroundBlurState(preferences?.viewPreferences?.backgroundBlur || 0);
|
||||||
|
}
|
||||||
|
if (preferences?.viewPreferences?.backgroundOpacity !== backgroundOpacity) {
|
||||||
|
setBackgroundOpacityState(preferences?.viewPreferences?.backgroundOpacity || 100);
|
||||||
|
}
|
||||||
|
}, [preferences?.viewPreferences?.backgroundImage, preferences?.viewPreferences?.backgroundBlur, preferences?.viewPreferences?.backgroundOpacity, backgroundImage, backgroundBlur, backgroundOpacity]);
|
||||||
|
|
||||||
|
// Apply background image to document body
|
||||||
|
useEffect(() => {
|
||||||
|
if (mounted) {
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
|
// Supprimer l'ancien élément de fond s'il existe
|
||||||
|
const existingBackground = document.getElementById('custom-background');
|
||||||
|
if (existingBackground) {
|
||||||
|
existingBackground.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backgroundImage) {
|
||||||
|
// Créer un élément div pour l'image de fond avec les effets
|
||||||
|
const backgroundElement = document.createElement('div');
|
||||||
|
backgroundElement.id = 'custom-background';
|
||||||
|
backgroundElement.style.position = 'fixed';
|
||||||
|
backgroundElement.style.top = '0';
|
||||||
|
backgroundElement.style.left = '0';
|
||||||
|
backgroundElement.style.width = '100%';
|
||||||
|
backgroundElement.style.height = '100%';
|
||||||
|
backgroundElement.style.zIndex = '-1';
|
||||||
|
backgroundElement.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
// Vérifier si c'est une URL d'image ou un preset
|
||||||
|
const PRESET_BACKGROUNDS = [
|
||||||
|
'theme-subtle',
|
||||||
|
'theme-primary',
|
||||||
|
'theme-accent',
|
||||||
|
'theme-success',
|
||||||
|
'theme-purple',
|
||||||
|
'theme-diagonal',
|
||||||
|
'theme-radial'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (PRESET_BACKGROUNDS.includes(backgroundImage)) {
|
||||||
|
// Appliquer le preset basé sur le thème
|
||||||
|
const presetStyles = {
|
||||||
|
'theme-subtle': 'linear-gradient(135deg, var(--background) 0%, var(--card-column) 100%)',
|
||||||
|
'theme-primary': 'linear-gradient(135deg, var(--background) 0%, color-mix(in srgb, var(--primary) 20%, var(--background)) 100%)',
|
||||||
|
'theme-accent': 'linear-gradient(135deg, var(--background) 0%, color-mix(in srgb, var(--accent) 20%, var(--background)) 100%)',
|
||||||
|
'theme-success': 'linear-gradient(135deg, var(--background) 0%, color-mix(in srgb, var(--success) 20%, var(--background)) 100%)',
|
||||||
|
'theme-purple': 'linear-gradient(135deg, var(--background) 0%, color-mix(in srgb, var(--purple) 20%, var(--background)) 100%)',
|
||||||
|
'theme-diagonal': 'linear-gradient(45deg, var(--background) 0%, color-mix(in srgb, var(--primary) 15%, var(--background)) 50%, color-mix(in srgb, var(--accent) 15%, var(--background)) 100%)',
|
||||||
|
'theme-radial': 'radial-gradient(circle at center, var(--background) 0%, color-mix(in srgb, var(--primary) 25%, var(--background)) 100%)'
|
||||||
|
};
|
||||||
|
|
||||||
|
backgroundElement.style.backgroundImage = presetStyles[backgroundImage as keyof typeof presetStyles];
|
||||||
|
} else {
|
||||||
|
// Appliquer l'URL d'image personnalisée
|
||||||
|
backgroundElement.style.backgroundImage = `url(${backgroundImage})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appliquer les propriétés communes
|
||||||
|
backgroundElement.style.backgroundSize = 'cover';
|
||||||
|
backgroundElement.style.backgroundPosition = 'center';
|
||||||
|
backgroundElement.style.backgroundRepeat = 'no-repeat';
|
||||||
|
backgroundElement.style.filter = `blur(${backgroundBlur}px)`;
|
||||||
|
backgroundElement.style.opacity = `${backgroundOpacity / 100}`;
|
||||||
|
|
||||||
|
// Ajouter l'élément au body
|
||||||
|
body.appendChild(backgroundElement);
|
||||||
|
body.classList.add('has-background-image');
|
||||||
|
} else {
|
||||||
|
// Supprimer l'image de fond
|
||||||
|
body.classList.remove('has-background-image');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [backgroundImage, backgroundBlur, backgroundOpacity, mounted]);
|
||||||
|
|
||||||
|
const setBackgroundImage = (image: string | undefined) => {
|
||||||
|
setBackgroundImageState(image);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackgroundContext.Provider value={{ backgroundImage, setBackgroundImage }}>
|
||||||
|
{children}
|
||||||
|
</BackgroundContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBackground() {
|
||||||
|
const context = useContext(BackgroundContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useBackground must be used within a BackgroundProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -57,7 +57,10 @@ const defaultPreferences: UserPreferences = {
|
|||||||
showFilters: true,
|
showFilters: true,
|
||||||
objectivesCollapsed: false,
|
objectivesCollapsed: false,
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
fontSize: 'medium'
|
fontSize: 'medium',
|
||||||
|
backgroundImage: undefined,
|
||||||
|
backgroundBlur: 0,
|
||||||
|
backgroundOpacity: 100
|
||||||
},
|
},
|
||||||
columnVisibility: {
|
columnVisibility: {
|
||||||
hiddenStatuses: []
|
hiddenStatuses: []
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ export interface ViewPreferences {
|
|||||||
objectivesCollapsed: boolean;
|
objectivesCollapsed: boolean;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
fontSize: 'small' | 'medium' | 'large';
|
fontSize: 'small' | 'medium' | 'large';
|
||||||
|
backgroundImage?: string;
|
||||||
|
backgroundBlur?: number;
|
||||||
|
backgroundOpacity?: number;
|
||||||
[key: string]:
|
[key: string]:
|
||||||
| boolean
|
| boolean
|
||||||
| 'tags'
|
| 'tags'
|
||||||
@@ -112,6 +115,8 @@ export interface ViewPreferences {
|
|||||||
| 'small'
|
| 'small'
|
||||||
| 'medium'
|
| 'medium'
|
||||||
| 'large'
|
| 'large'
|
||||||
|
| string
|
||||||
|
| number
|
||||||
| undefined;
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ const DEFAULT_PREFERENCES: UserPreferences = {
|
|||||||
showFilters: true,
|
showFilters: true,
|
||||||
objectivesCollapsed: false,
|
objectivesCollapsed: false,
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
fontSize: 'medium'
|
fontSize: 'medium',
|
||||||
|
backgroundImage: undefined,
|
||||||
|
backgroundBlur: 0,
|
||||||
|
backgroundOpacity: 100
|
||||||
},
|
},
|
||||||
columnVisibility: {
|
columnVisibility: {
|
||||||
hiddenStatuses: []
|
hiddenStatuses: []
|
||||||
|
|||||||
Reference in New Issue
Block a user