Files
Momento/memento-note/app/(main)/settings/appearance/appearance-settings-client.tsx
Antigravity 0784c94242
Some checks failed
CI / Lint, Test & Build (push) Failing after 57s
CI / Deploy production (on server) (push) Has been skipped
feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
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>
2026-05-24 23:03:16 +00:00

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>
)
}