feat(settings): wire appearance controls to ThemeProvider and backend
This commit is contained in:
parent
50482a01fd
commit
9b33f3283d
3 changed files with 212 additions and 79 deletions
|
|
@ -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 (
|
||||
<div className="space-y-8 animate-fadeIn max-w-4xl mx-auto pb-20">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-end border-b border-border/50 pb-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-heading font-bold text-foreground mb-2">
|
||||
|
|
@ -46,53 +96,36 @@ export const AppearanceSettingsView: React.FC = () => {
|
|||
Customize your visual experience.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => addToast('Appearance settings saved', 'success')}
|
||||
>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Theme Selection */}
|
||||
{/* Theme + High Contrast */}
|
||||
<Card variant="default">
|
||||
<h3 className="text-lg font-bold text-foreground mb-6 flex items-center gap-2">
|
||||
<Palette className="w-5 h-5 text-muted-foreground" /> Theme
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
{[
|
||||
{
|
||||
id: ThemeVariant.NEON,
|
||||
label: 'Neon Dark',
|
||||
icon: <Moon className="w-6 h-6" />,
|
||||
},
|
||||
{
|
||||
id: ThemeVariant.LIGHT,
|
||||
label: 'Light Mode',
|
||||
icon: <Sun className="w-6 h-6" />,
|
||||
},
|
||||
{
|
||||
id: ThemeVariant.GAMING,
|
||||
label: 'High Contrast',
|
||||
icon: <Monitor className="w-6 h-6" />,
|
||||
},
|
||||
{ id: 'dark' as const, label: 'Neon Dark', icon: <Moon className="w-6 h-6" /> },
|
||||
{ id: 'light' as const, label: 'Light Mode', icon: <Sun className="w-6 h-6" /> },
|
||||
{ id: 'system' as const, label: 'System', icon: <Monitor className="w-6 h-6" /> },
|
||||
].map((opt) => (
|
||||
<div
|
||||
key={opt.id}
|
||||
onClick={() => {
|
||||
const map: Record<ThemeVariant, 'dark' | 'light'> = {
|
||||
[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'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`p-4 rounded-full ${theme === opt.id ? 'bg-primary text-black' : 'bg-muted text-muted-foreground'}`}
|
||||
|
|
@ -112,9 +145,21 @@ export const AppearanceSettingsView: React.FC = () => {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-card rounded-lg border border-border">
|
||||
<div>
|
||||
<div className="text-sm font-bold text-foreground">High Contrast</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
WCAG AA compliant colors for better visibility
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={contrast === 'high'}
|
||||
onCheckedChange={(checked) => setContrast(checked ? 'high' : 'normal')}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Display Density */}
|
||||
{/* Density + Typography */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<Card variant="default">
|
||||
<h3 className="text-lg font-bold text-foreground mb-6 flex items-center gap-2">
|
||||
|
|
@ -122,35 +167,34 @@ export const AppearanceSettingsView: React.FC = () => {
|
|||
</h3>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
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) => (
|
||||
<div
|
||||
key={opt.id}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${density === opt.id ? 'border-destructive' : 'border-border'}`}
|
||||
>
|
||||
{density === opt.id && (
|
||||
<div className="w-2 h-2 rounded-full bg-destructive"></div>
|
||||
<div className="w-2 h-2 rounded-full bg-destructive" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={`text-sm font-bold ${density === opt.id ? 'text-foreground' : 'text-foreground'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</div>
|
||||
<div className="text-sm font-bold text-foreground">{opt.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{opt.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -170,11 +214,12 @@ export const AppearanceSettingsView: React.FC = () => {
|
|||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="12"
|
||||
min="14"
|
||||
max="20"
|
||||
value={fontSize}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div
|
||||
className="mt-4 p-4 bg-card rounded border border-border text-foreground"
|
||||
|
|
@ -187,23 +232,32 @@ export const AppearanceSettingsView: React.FC = () => {
|
|||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Colors & Layout */}
|
||||
{/* Accent + Layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<Card variant="default">
|
||||
<h3 className="text-lg font-bold text-foreground mb-6 flex items-center gap-2">
|
||||
<Palette className="w-5 h-5 text-success" /> Accent Color
|
||||
</h3>
|
||||
<div className="flex gap-4">
|
||||
{accents.map((col) => (
|
||||
{ACCENT_PRESETS.map((col) => (
|
||||
<div
|
||||
key={col.id}
|
||||
onClick={() => 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 && (
|
||||
<Check className="w-5 h-5 text-black" />
|
||||
)}
|
||||
{accentHue === col.hue && <Check className="w-5 h-5 text-black" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -225,7 +279,7 @@ export const AppearanceSettingsView: React.FC = () => {
|
|||
</div>
|
||||
<Switch
|
||||
checked={showSidebar}
|
||||
onChange={() => setShowSidebar(!showSidebar)}
|
||||
onCheckedChange={() => setShowSidebar(!showSidebar)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue