Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note) avec activation guidée, tableau éditable, kanban et suppression de colonnes. Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN. Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la robustesse du serveur MCP (config, validation, rate-limit, métriques). Co-authored-by: Cursor <cursoragent@cursor.com>
316 lines
13 KiB
TypeScript
316 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { updateAISettings } from '@/app/actions/ai-settings'
|
|
import { updateUserSettings } from '@/app/actions/user-settings'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { toast } from 'sonner'
|
|
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react'
|
|
import { applyDocumentTheme, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
|
|
import {
|
|
NOTES_LAYOUT_STORAGE_KEY,
|
|
parseNotesLayoutMode,
|
|
setNotesLayoutPreference,
|
|
} from '@/lib/notes-view-preference'
|
|
import { isClassicLayoutMode, type NotesClassicLayoutMode } from '@/components/notes-list-views'
|
|
import { motion } from 'motion/react'
|
|
|
|
const PRESET_COLORS = [
|
|
{ name: 'Warm Earth', value: '#A47148' },
|
|
{ name: 'Sage', value: '#4E594A' },
|
|
{ name: 'Terracotta', value: '#B1523E' },
|
|
{ name: 'Iron', value: '#4A5568' },
|
|
{ name: 'Charcoal', value: '#1E293B' },
|
|
{ name: 'Slate', value: '#475569' },
|
|
{ name: 'Olive', value: '#7C8363' },
|
|
{ name: 'Wine', value: '#722F37' },
|
|
]
|
|
|
|
interface AppearanceSettingsClientProps {
|
|
initialFontSize: string
|
|
initialTheme: string
|
|
initialFontFamily?: string
|
|
initialAccentColor?: string
|
|
}
|
|
|
|
export function AppearanceSettingsClient({
|
|
initialFontSize,
|
|
initialTheme,
|
|
initialFontFamily = 'inter',
|
|
initialAccentColor = '#A47148',
|
|
}: AppearanceSettingsClientProps) {
|
|
const { t } = useLanguage()
|
|
const [theme, setTheme] = useState<ThemeId>(normalizeThemeId(initialTheme || 'light'))
|
|
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
|
const [fontFamily, setFontFamily] = useState(initialFontFamily)
|
|
const [accentColor, setAccentColor] = useState(initialAccentColor)
|
|
const [notesLayout, setNotesLayout] = useState<NotesClassicLayoutMode>('list')
|
|
|
|
useEffect(() => {
|
|
const stored = parseNotesLayoutMode(localStorage.getItem(NOTES_LAYOUT_STORAGE_KEY))
|
|
setNotesLayout(isClassicLayoutMode(stored) ? stored : 'list')
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
document.documentElement.style.setProperty('--color-brand-accent', accentColor)
|
|
}, [accentColor])
|
|
|
|
const handleAccentColorChange = async (color: string) => {
|
|
setAccentColor(color)
|
|
document.documentElement.style.setProperty('--color-brand-accent', color)
|
|
localStorage.setItem('accent-color', color)
|
|
await updateUserSettings({ accentColor: color })
|
|
}
|
|
|
|
const handleThemeChange = async (value: string) => {
|
|
const next = normalizeThemeId(value)
|
|
setTheme(next)
|
|
localStorage.setItem('theme-preference', next)
|
|
applyDocumentTheme(next)
|
|
await updateUserSettings({ theme: next })
|
|
toast.success(t('settings.settingsSaved'))
|
|
}
|
|
|
|
const handleFontSizeChange = async (value: string) => {
|
|
setFontSize(value)
|
|
const map: Record<string, string> = { small: '14px', medium: '16px', large: '18px', 'extra-large': '20px' }
|
|
document.documentElement.style.setProperty('--user-font-size', map[value] || '16px')
|
|
await updateAISettings({ fontSize: value as any })
|
|
toast.success(t('settings.settingsSaved'))
|
|
}
|
|
|
|
const handleFontFamilyChange = async (value: string) => {
|
|
const font = value === 'system' ? 'system'
|
|
: value === 'playfair' ? 'playfair'
|
|
: value === 'jetbrains' ? 'jetbrains'
|
|
: 'inter'
|
|
setFontFamily(font)
|
|
localStorage.setItem('font-family', font)
|
|
const root = document.documentElement
|
|
root.classList.remove('font-system', 'font-playfair', 'font-jetbrains')
|
|
if (font === 'system') root.classList.add('font-system')
|
|
if (font === 'playfair') root.classList.add('font-playfair')
|
|
if (font === 'jetbrains') root.classList.add('font-jetbrains')
|
|
await updateAISettings({ fontFamily: font as 'inter' | 'playfair' | 'jetbrains' | 'system' })
|
|
toast.success(t('settings.settingsSaved'))
|
|
}
|
|
|
|
const handleNotesLayoutChange = (value: string) => {
|
|
const layout = value === 'grid' ? 'grid' : value === 'table' ? 'table' : 'list'
|
|
setNotesLayout(layout)
|
|
setNotesLayoutPreference(layout)
|
|
window.dispatchEvent(new CustomEvent('memento-notes-layout-change', { detail: { layout } }))
|
|
toast.success(t('settings.settingsSaved'))
|
|
}
|
|
|
|
const SelectCard = ({
|
|
icon: Icon,
|
|
title,
|
|
description,
|
|
value,
|
|
options,
|
|
onChange,
|
|
optionGroups,
|
|
}: {
|
|
icon: React.ElementType
|
|
title: string
|
|
description: string
|
|
value: string
|
|
options?: { value: string; label: string }[]
|
|
optionGroups?: { label: string; options: { value: string; label: string }[] }[]
|
|
onChange: (v: string) => void
|
|
}) => (
|
|
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-8 group transition-all duration-300 hover:shadow-xl hover:shadow-slate/5">
|
|
<div className="flex items-center gap-5">
|
|
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border group-hover:scale-110 transition-transform duration-300">
|
|
<Icon className="h-5 w-5" />
|
|
</div>
|
|
<div className="space-y-0.5 text-left">
|
|
<h4 className="text-base font-bold text-foreground">{title}</h4>
|
|
<p className="text-[11px] text-muted-foreground leading-tight">{description}</p>
|
|
</div>
|
|
</div>
|
|
<div className="relative group/select">
|
|
<select
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="w-full bg-white/50 dark:bg-black/40 border border-border rounded-xl px-5 py-4 text-sm outline-none focus:ring-1 ring-slate/20 appearance-none cursor-pointer text-foreground font-bold transition-all hover:bg-white dark:hover:bg-black/60"
|
|
>
|
|
{optionGroups
|
|
? optionGroups.map((g) => (
|
|
<optgroup key={g.label} label={g.label}>
|
|
{g.options.map((o) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</optgroup>
|
|
))
|
|
: options?.map((o) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground group-hover/select:text-foreground transition-colors">
|
|
<svg width="10" height="6" viewBox="0 0 10 6" fill="none">
|
|
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
const themeOptionGroups = [
|
|
{
|
|
label: t('settings.themeBaseGroup'),
|
|
options: [
|
|
{ value: 'light', label: t('settings.themeLight') },
|
|
{ value: 'dark', label: t('settings.themeDark') },
|
|
{ value: 'auto', label: t('settings.themeSystem') },
|
|
],
|
|
},
|
|
{
|
|
label: t('settings.themePalettesGroup'),
|
|
options: [
|
|
{ value: 'sepia', label: t('settings.themeSepia') },
|
|
{ value: 'midnight', label: t('settings.themeMidnight') },
|
|
{ value: 'rose', label: t('settings.themeRose') },
|
|
{ value: 'green', label: t('settings.themeGreen') },
|
|
{ value: 'lavender', label: t('settings.themeLavender') },
|
|
{ value: 'sand', label: t('settings.themeSand') },
|
|
{ value: 'ocean', label: t('settings.themeOcean') },
|
|
{ value: 'sunset', label: t('settings.themeSunset') },
|
|
{ value: 'blue', label: t('settings.themeBlue') },
|
|
],
|
|
},
|
|
]
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="space-y-16 pb-20"
|
|
>
|
|
<div className="space-y-10">
|
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
|
{t('appearance.description')}
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
{/* Accent Color Section */}
|
|
<div className="md:col-span-2 bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-10 space-y-8 group transition-all duration-300 hover:shadow-xl hover:shadow-brand-accent/5">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-5">
|
|
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl border border-border group-hover:scale-110 transition-transform duration-300 shadow-sm" style={{ color: accentColor }}>
|
|
<Palette size={20} />
|
|
</div>
|
|
<div className="space-y-0.5 text-left">
|
|
<h4 className="text-base font-bold text-foreground">{t('appearance.accentColorTitle')}</h4>
|
|
<p className="text-[11px] text-muted-foreground leading-tight">{t('appearance.accentColorDescription')}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3 bg-slate-50 dark:bg-black/20 px-4 py-2 rounded-xl border border-border/40">
|
|
<div className="w-4 h-4 rounded-full border border-border/60" style={{ backgroundColor: accentColor }} />
|
|
<span className="text-xs font-mono font-medium text-muted-foreground uppercase tracking-widest">{accentColor}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-4">
|
|
{PRESET_COLORS.map((color) => (
|
|
<button
|
|
key={color.value}
|
|
onClick={() => handleAccentColorChange(color.value)}
|
|
className={`relative w-12 h-12 rounded-2xl transition-all duration-300 hover:scale-110 flex items-center justify-center p-1 border-2 ${
|
|
accentColor.toLowerCase() === color.value.toLowerCase()
|
|
? 'border-foreground shadow-lg'
|
|
: 'border-transparent hover:border-muted-foreground/20'
|
|
}`}
|
|
title={color.name}
|
|
>
|
|
<div
|
|
className="w-full h-full rounded-xl shadow-inner"
|
|
style={{ backgroundColor: color.value }}
|
|
/>
|
|
{accentColor.toLowerCase() === color.value.toLowerCase() && (
|
|
<motion.div
|
|
layoutId="color-check"
|
|
className="absolute inset-0 flex items-center justify-center text-white mix-blend-difference"
|
|
>
|
|
<Palette size={14} />
|
|
</motion.div>
|
|
)}
|
|
</button>
|
|
))}
|
|
|
|
<div className="h-12 w-px bg-border/40 mx-2" />
|
|
|
|
<div className="relative group/custom">
|
|
<input
|
|
type="color"
|
|
value={accentColor}
|
|
onChange={(e) => handleAccentColorChange(e.target.value)}
|
|
className="w-12 h-12 rounded-2xl cursor-pointer opacity-0 absolute inset-0 z-10"
|
|
/>
|
|
<div className="w-12 h-12 rounded-2xl border-2 border-dashed border-muted-foreground/30 flex items-center justify-center text-muted-foreground transition-all group-hover/custom:border-foreground group-hover/custom:text-foreground">
|
|
<Maximize size={16} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<SelectCard
|
|
icon={Palette}
|
|
title={t('settings.theme')}
|
|
description={t('appearance.selectTheme')}
|
|
value={theme}
|
|
optionGroups={themeOptionGroups}
|
|
onChange={handleThemeChange}
|
|
/>
|
|
|
|
<SelectCard
|
|
icon={Type}
|
|
title={t('profile.fontSize')}
|
|
description={t('profile.fontSizeDescription')}
|
|
value={fontSize}
|
|
options={[
|
|
{ value: 'small', label: t('profile.fontSizeSmall') },
|
|
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
|
{ value: 'large', label: t('profile.fontSizeLarge') },
|
|
{ value: 'extra-large', label: t('profile.fontSizeExtraLarge') },
|
|
]}
|
|
onChange={handleFontSizeChange}
|
|
/>
|
|
|
|
<SelectCard
|
|
icon={Type}
|
|
title={t('appearance.fontFamilyLabel')}
|
|
description={t('appearance.fontFamilyDescription')}
|
|
value={fontFamily}
|
|
options={[
|
|
{ value: 'inter', label: t('appearance.fontInterDefault') },
|
|
{ value: 'playfair', label: t('appearance.fontPlayfairDisplay') },
|
|
{ value: 'jetbrains', label: t('appearance.fontJetBrainsMono') },
|
|
{ value: 'system', label: t('appearance.fontSystem') },
|
|
]}
|
|
onChange={handleFontFamilyChange}
|
|
/>
|
|
|
|
<SelectCard
|
|
icon={LayoutGrid}
|
|
title={t('settings.notesViewLabel')}
|
|
description={t('settings.notesViewDescription')}
|
|
value={notesLayout === 'grid' ? 'masonry' : notesLayout === 'table' ? 'table' : 'list'}
|
|
options={[
|
|
{ value: 'masonry', label: t('settings.notesViewMasonry') },
|
|
{ value: 'list', label: t('settings.notesViewList') },
|
|
{ value: 'table', label: t('settings.notesViewTable') },
|
|
]}
|
|
onChange={(v) => handleNotesLayoutChange(v === 'masonry' ? 'grid' : v === 'table' ? 'table' : 'list')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|