diff --git a/apps/web/src/components/settings/appearance/AppearanceSettingsView.tsx b/apps/web/src/components/settings/appearance/AppearanceSettingsView.tsx index b4aa87bc7..04094cc5f 100644 --- a/apps/web/src/components/settings/appearance/AppearanceSettingsView.tsx +++ b/apps/web/src/components/settings/appearance/AppearanceSettingsView.tsx @@ -1,8 +1,7 @@ -import React, { useState } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { Card } from '../../ui/card'; import { Button } from '../../ui/button'; import { useTheme } from '../../../components/theme/ThemeProvider'; -import { ThemeVariant } from '../../../types'; import { Moon, Sun, @@ -15,28 +14,79 @@ import { } from 'lucide-react'; import { useToast } from '../../../components/feedback/ToastProvider'; import { Switch } from '../../ui/switch'; +import { useAuth } from '@/features/auth/hooks/useAuth'; +import { userService } from '@/services/userService'; + +const ACCENT_PRESETS = [ + { id: 'indigo', hue: 220, hex: '#7c9dd6' }, + { id: 'sage', hue: 120, hex: '#7a9e6c' }, + { id: 'vermillion', hue: 15, hex: '#d4634a' }, + { id: 'gold', hue: 45, hex: '#c9a84c' }, + { id: 'sakura', hue: 340, hex: '#e0a0b8' }, +]; export const AppearanceSettingsView: React.FC = () => { - const { theme, setTheme } = useTheme(); + const { + theme, + setTheme, + contrast, + setContrast, + density, + setDensity, + accentHue, + setAccentHue, + fontSize, + setFontSize, + } = useTheme(); const { addToast } = useToast(); - const [density, setDensity] = useState<'comfortable' | 'compact' | 'cozy'>( - 'comfortable', - ); - const [fontSize, setFontSize] = useState(16); - const [accentColor, setAccentColor] = useState('cyan'); - const [showSidebar, setShowSidebar] = useState(true); + const { isAuthenticated } = useAuth(); + const [showSidebar, setShowSidebar] = React.useState(true); - const accents = [ - { id: 'indigo', hex: '#7c9dd6' }, - { id: 'sage', hex: '#7a9e6c' }, - { id: 'vermillion', hex: '#d4634a' }, - { id: 'gold', hex: '#c9a84c' }, - { id: 'sakura', hex: '#e0a0b8' }, - ]; + const loadPreferences = useCallback(async () => { + if (!isAuthenticated) return; + try { + const prefs = await userService.getPreferences(); + const themeVal = prefs.theme as 'dark' | 'light' | 'system'; + if (themeVal && ['dark', 'light', 'system'].includes(themeVal)) { + setTheme(themeVal); + } + setContrast(prefs.contrast === 'high' ? 'high' : 'normal'); + const densityVal = prefs.density as 'comfortable' | 'compact' | 'cozy'; + if (densityVal && ['comfortable', 'compact', 'cozy'].includes(densityVal)) { + setDensity(densityVal); + } + setAccentHue(prefs.accentHue ?? 220); + setFontSize(Math.min(20, Math.max(14, prefs.fontSize ?? 16))); + } catch { + /* ignore, use local state */ + } + }, [isAuthenticated, setTheme, setContrast, setDensity, setAccentHue, setFontSize]); + + useEffect(() => { + loadPreferences(); + }, [loadPreferences]); + + const handleSave = async () => { + if (isAuthenticated) { + try { + await userService.updatePreferences({ + theme, + contrast, + density, + accentHue, + fontSize, + }); + addToast('Appearance settings saved', 'success'); + } catch { + addToast('Failed to save preferences', 'error'); + } + } else { + addToast('Appearance settings saved', 'success'); + } + }; return (
- {/* Header */}

@@ -46,53 +96,36 @@ export const AppearanceSettingsView: React.FC = () => { Customize your visual experience.

-
- {/* Theme Selection */} + {/* Theme + High Contrast */}

Theme

-
+
{[ - { - id: ThemeVariant.NEON, - label: 'Neon Dark', - icon: , - }, - { - id: ThemeVariant.LIGHT, - label: 'Light Mode', - icon: , - }, - { - id: ThemeVariant.GAMING, - label: 'High Contrast', - icon: , - }, + { id: 'dark' as const, label: 'Neon Dark', icon: }, + { id: 'light' as const, label: 'Light Mode', icon: }, + { id: 'system' as const, label: 'System', icon: }, ].map((opt) => (
{ - const map: Record = { - [ThemeVariant.NEON]: 'dark', - [ThemeVariant.LIGHT]: 'light', - [ThemeVariant.GAMING]: 'dark', - [ThemeVariant.NATURE]: 'light', - [ThemeVariant.TERMINAL]: 'dark', // Gaming treated as dark for now - }; - setTheme(map[opt.id]); + role="button" + tabIndex={0} + onClick={() => setTheme(opt.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setTheme(opt.id); + } }} - className={` - cursor-pointer p-6 rounded-xl border-2 transition-all flex flex-col items-center gap-4 relative - ${theme === opt.id ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-border'} - `} + className={`cursor-pointer p-6 rounded-xl border-2 transition-all flex flex-col items-center gap-4 relative ${ + theme === opt.id ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-border' + }`} >
{
))}
+
+
+
High Contrast
+
+ WCAG AA compliant colors for better visibility +
+
+ setContrast(checked ? 'high' : 'normal')} + /> +
- {/* Display Density */} + {/* Density + Typography */}

@@ -122,35 +167,34 @@ export const AppearanceSettingsView: React.FC = () => {

{[ - { - id: 'comfortable', - label: 'Comfortable', - desc: 'More whitespace for readability', - }, - { id: 'cozy', label: 'Cozy', desc: 'Balanced spacing' }, - { id: 'compact', label: 'Compact', desc: 'Maximum data density' }, + { id: 'comfortable' as const, label: 'Comfortable', desc: 'More whitespace for readability' }, + { id: 'cozy' as const, label: 'Cozy', desc: 'Balanced spacing' }, + { id: 'compact' as const, label: 'Compact', desc: 'Maximum data density' }, ].map((opt) => (
setDensity(opt.id as 'comfortable' | 'compact' | 'cozy')} - className={` - flex items-center gap-4 p-4 rounded-lg border cursor-pointer transition-all - ${density === opt.id ? 'bg-primary/10 border-primary' : 'bg-card border-border hover:bg-muted/50'} - `} + role="button" + tabIndex={0} + onClick={() => setDensity(opt.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setDensity(opt.id); + } + }} + className={`flex items-center gap-4 p-4 rounded-lg border cursor-pointer transition-all ${ + density === opt.id ? 'bg-primary/10 border-primary' : 'bg-card border-border hover:bg-muted/50' + }`} >
{density === opt.id && ( -
+
)}
-
- {opt.label} -
+
{opt.label}
{opt.desc}
@@ -170,11 +214,12 @@ export const AppearanceSettingsView: React.FC = () => {
setFontSize(Number(e.target.value))} className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-warning [&::-webkit-slider-thumb]:rounded-full" + aria-label="Font size" />
{
- {/* Colors & Layout */} + {/* Accent + Layout */}

Accent Color

- {accents.map((col) => ( + {ACCENT_PRESETS.map((col) => (
setAccentColor(col.id)} - className={`w-10 h-10 rounded-full cursor-pointer flex items-center justify-center transition-opacity hover:opacity-80 ring-2 ring-offset-2 ring-offset-background ${accentColor === col.id ? 'ring-white' : 'ring-transparent'}`} + role="button" + tabIndex={0} + onClick={() => setAccentHue(col.hue)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setAccentHue(col.hue); + } + }} + className={`w-10 h-10 rounded-full cursor-pointer flex items-center justify-center transition-opacity hover:opacity-80 ring-2 ring-offset-2 ring-offset-background ${ + accentHue === col.hue ? 'ring-white' : 'ring-transparent' + }`} style={{ backgroundColor: col.hex }} + aria-label={`Accent color ${col.id}`} > - {accentColor === col.id && ( - - )} + {accentHue === col.hue && }
))}
@@ -225,7 +279,7 @@ export const AppearanceSettingsView: React.FC = () => {
setShowSidebar(!showSidebar)} + onCheckedChange={() => setShowSidebar(!showSidebar)} />
diff --git a/apps/web/src/mocks/handlers-misc.ts b/apps/web/src/mocks/handlers-misc.ts index 09506fb3a..e4a0fe426 100644 --- a/apps/web/src/mocks/handlers-misc.ts +++ b/apps/web/src/mocks/handlers-misc.ts @@ -339,6 +339,47 @@ export const handlersMisc = [ return HttpResponse.json({ success: true }); }), + // v0.801: User appearance preferences (contrast, density, accentHue, fontSize) + http.get('*/api/v1/users/me/preferences', () => { + return HttpResponse.json({ + success: true, + data: { + user_id: 'user-1', + theme: 'dark', + language: 'en', + timezone: 'UTC', + notifications: {}, + privacy: {}, + audio: {}, + contrast: 'normal', + density: 'comfortable', + accentHue: 220, + fontSize: 16, + updated_at: new Date().toISOString(), + }, + }); + }), + http.put('*/api/v1/users/me/preferences', async ({ request }) => { + const body = (await request.json()) as Record; + return HttpResponse.json({ + success: true, + data: { + user_id: 'user-1', + theme: body.theme ?? 'dark', + language: body.language ?? 'en', + timezone: body.timezone ?? 'UTC', + notifications: body.notifications ?? {}, + privacy: body.privacy ?? {}, + audio: body.audio ?? {}, + contrast: body.contrast ?? 'normal', + density: body.density ?? 'comfortable', + accentHue: body.accentHue ?? 220, + fontSize: body.fontSize ?? 16, + updated_at: new Date().toISOString(), + }, + }); + }), + http.get('*/api/v1/users/by-username/:username', ({ params }) => { if (params.username === 'notfound') { return HttpResponse.json({ message: 'User not found' }, { status: 404 }); diff --git a/apps/web/src/services/userService.ts b/apps/web/src/services/userService.ts index 9fc85a7c1..22ff237a8 100644 --- a/apps/web/src/services/userService.ts +++ b/apps/web/src/services/userService.ts @@ -112,6 +112,44 @@ export const userService = { return response.data; }, + /** + * Récupère les préférences de l'utilisateur connecté (v0.801: appearance) + */ + getPreferences: async () => { + const response = await apiClient.get<{ + user_id: string; + theme: string; + language: string; + timezone: string; + contrast: string; + density: string; + accentHue: number; + fontSize: number; + }>('/users/me/preferences'); + return response.data; + }, + + /** + * Met à jour les préférences de l'utilisateur connecté (v0.801: appearance) + */ + updatePreferences: async (data: { + theme?: string; + contrast?: string; + density?: string; + accentHue?: number; + fontSize?: number; + }) => { + const response = await apiClient.put<{ + user_id: string; + theme: string; + contrast: string; + density: string; + accentHue: number; + fontSize: number; + }>('/users/me/preferences', data); + return response.data; + }, + /** * Exporte les données de l'utilisateur (GDPR) */