feat: unified settings modal consolidating theme, language, sound, and send shortcut preferences

This commit is contained in:
Nicolas Varrot
2026-02-15 04:04:49 +00:00
parent f28ab429a3
commit bc59ed4a91
3 changed files with 282 additions and 15 deletions

View File

@@ -1,9 +1,8 @@
import { useCallback, useState, useRef, useEffect } from 'react';
import { Menu, Sparkles, LogOut, Volume2, VolumeOff, Cpu, Bot, Download, Minimize2, Info, Copy, Check } from 'lucide-react';
import { Menu, Sparkles, LogOut, Cpu, Bot, Download, Minimize2, Info, Copy, Check, Settings } from 'lucide-react';
import type { ConnectionStatus, Session, ChatMessage } from '../types';
import { useT } from '../hooks/useLocale';
import { LanguageSelector } from './LanguageSelector';
import { ThemeSwitcher } from './ThemeSwitcher';
import { SettingsModal } from './SettingsModal';
import { sessionDisplayName } from '../lib/sessionName';
import { messagesToMarkdown, downloadFile } from '../lib/exportChat';
@@ -25,6 +24,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
const t = useT();
const sessionLabel = activeSessionData ? sessionDisplayName(activeSessionData) : (sessionKey.split(':').pop() || sessionKey);
const [showSessionInfo, setShowSessionInfo] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const sessionInfoRef = useRef<HTMLDivElement>(null);
// Close popover on outside click
@@ -77,16 +77,6 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
)}
</div>
<div className="flex items-center gap-2 text-sm">
{onToggleSound && (
<button
onClick={onToggleSound}
aria-label={soundEnabled ? t('header.soundOff') : t('header.soundOn')}
className="hidden sm:block p-2 rounded-2xl hover:bg-[var(--pc-hover)] text-pc-text-muted hover:text-pc-text transition-colors"
title={soundEnabled ? t('header.soundOff') : t('header.soundOn')}
>
{soundEnabled ? <Volume2 size={16} /> : <VolumeOff size={16} />}
</button>
)}
{messages && messages.length > 0 && (
<button
onClick={handleExport}
@@ -97,8 +87,14 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
<Download size={16} />
</button>
)}
<span className="hidden sm:contents"><ThemeSwitcher /></span>
<span className="hidden sm:contents"><LanguageSelector /></span>
<button
onClick={() => setSettingsOpen(true)}
aria-label={t('settings.title')}
className="p-2 rounded-2xl hover:bg-[var(--pc-hover)] text-pc-text-muted hover:text-pc-text transition-colors"
title={t('settings.title')}
>
<Settings size={16} />
</button>
{status === 'connected' ? (
<div className="flex items-center gap-2 rounded-2xl border border-pc-border bg-pc-elevated/30 px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.4)]" />
@@ -154,6 +150,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
</div>
);
})()}
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} soundEnabled={soundEnabled} onToggleSound={onToggleSound} />
</>
);
}

View File

@@ -0,0 +1,222 @@
import { useEffect } from 'react';
import { X, Settings, Sun, Moon, Monitor, Laptop, Check, Volume2, VolumeOff } from 'lucide-react';
import { useTheme } from '../hooks/useTheme';
import { useSendShortcut } from '../hooks/useSendShortcut';
import { useT, useLocale } from '../hooks/useLocale';
import { setLocale, supportedLocales, localeLabels } from '../lib/i18n';
import type { ThemeName, AccentColor } from '../contexts/ThemeContextDef';
import type { TranslationKey } from '../lib/i18n';
const themeOptions: { value: ThemeName; icon: typeof Sun; labelKey: TranslationKey }[] = [
{ value: 'system', icon: Laptop, labelKey: 'theme.system' },
{ value: 'dark', icon: Moon, labelKey: 'theme.dark' },
{ value: 'light', icon: Sun, labelKey: 'theme.light' },
{ value: 'oled', icon: Monitor, labelKey: 'theme.oled' },
];
const accentOptions: { value: AccentColor; color: string }[] = [
{ value: 'cyan', color: '#22d3ee' },
{ value: 'violet', color: '#8b5cf6' },
{ value: 'emerald', color: '#10b981' },
{ value: 'amber', color: '#f59e0b' },
{ value: 'rose', color: '#f43f5e' },
{ value: 'blue', color: '#3b82f6' },
];
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
interface Props {
open: boolean;
onClose: () => void;
soundEnabled?: boolean;
onToggleSound?: () => void;
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="text-[10px] uppercase tracking-wider text-pc-text-faint font-semibold mt-5 mb-2 first:mt-0">
{children}
</div>
);
}
function ToggleSwitch({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) {
return (
<button
role="switch"
aria-checked={checked}
aria-label={label}
onClick={onChange}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pc-accent/50 ${
checked ? 'bg-pc-accent' : 'bg-pc-border-strong'
}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out ${
checked ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
);
}
export function SettingsModal({ open, onClose, soundEnabled, onToggleSound }: Props) {
const t = useT();
const { theme, accent, setTheme, setAccent } = useTheme();
const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut();
const currentLocale = useLocale();
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onClose]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('settings.title')}
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
{/* Modal */}
<div
className="relative w-full max-w-sm mx-4 rounded-3xl border border-pc-border bg-[var(--pc-bg-base)]/95 backdrop-blur-xl shadow-2xl animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-pc-border">
<div className="flex items-center gap-2.5">
<Settings size={18} className="text-pc-accent-light/70" />
<h2 className="text-sm font-semibold text-pc-text">{t('settings.title')}</h2>
</div>
<button
onClick={onClose}
className="h-8 w-8 rounded-xl flex items-center justify-center text-pc-text-muted hover:text-pc-text hover:bg-[var(--pc-hover)] transition-colors"
aria-label="Close"
>
<X size={16} />
</button>
</div>
{/* Body */}
<div className="px-6 py-4 max-h-[60vh] overflow-y-auto">
{/* Appearance */}
<SectionTitle>{t('settings.appearance')}</SectionTitle>
{/* Theme mode */}
<div className="mb-3">
<div className="text-xs text-pc-text-secondary mb-2">{t('theme.mode')}</div>
<div className="flex gap-1.5">
{themeOptions.map(opt => {
const Icon = opt.icon;
const active = theme === opt.value;
return (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
aria-pressed={active}
className={`flex-1 flex flex-col items-center gap-1 py-2 rounded-xl border text-xs transition-all ${
active
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light'
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
}`}
>
<Icon size={16} />
<span>{t(opt.labelKey)}</span>
</button>
);
})}
</div>
</div>
{/* Accent color */}
<div className="mb-1">
<div className="text-xs text-pc-text-secondary mb-2">{t('theme.accent')}</div>
<div className="flex gap-2">
{accentOptions.map(opt => (
<button
key={opt.value}
onClick={() => setAccent(opt.value)}
className="relative h-7 w-7 rounded-full border-2 transition-all flex items-center justify-center"
aria-pressed={accent === opt.value}
aria-label={`${opt.value} accent`}
style={{
backgroundColor: opt.color,
borderColor: accent === opt.value ? opt.color : 'transparent',
boxShadow: accent === opt.value ? `0 0 8px ${opt.color}40` : 'none',
}}
title={opt.value}
>
{accent === opt.value && <Check size={14} className="text-white drop-shadow" />}
</button>
))}
</div>
</div>
{/* Language */}
<SectionTitle>{t('settings.language')}</SectionTitle>
<div className="flex flex-wrap gap-1.5">
{supportedLocales.map(loc => (
<button
key={loc}
onClick={() => setLocale(loc)}
aria-pressed={currentLocale === loc}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl border text-xs transition-all ${
currentLocale === loc
? 'border-pc-accent/40 bg-pc-accent/10 text-pc-accent-light font-medium'
: 'border-pc-border text-pc-text-muted hover:bg-[var(--pc-hover)]'
}`}
>
{currentLocale === loc && <Check size={12} />}
{localeLabels[loc] || loc.toUpperCase()}
</button>
))}
</div>
{/* Chat */}
<SectionTitle>{t('settings.chat')}</SectionTitle>
<div className="flex items-center justify-between py-1.5">
<div className="text-xs text-pc-text-secondary">{t('settings.sendShortcut')}</div>
<button
onClick={toggleSendShortcut}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-pc-border text-xs text-pc-text-secondary hover:bg-[var(--pc-hover)] transition-colors"
>
{sendOnEnter
? t('settings.sendEnter')
: (isMac ? '⌘+' : 'Ctrl+') + t('settings.sendEnter')
}
</button>
</div>
{/* Notifications */}
{onToggleSound && (
<>
<SectionTitle>{t('settings.notifications')}</SectionTitle>
<div className="flex items-center justify-between py-1.5">
<div className="flex items-center gap-2 text-xs text-pc-text-secondary">
{soundEnabled ? <Volume2 size={14} /> : <VolumeOff size={14} />}
{t('settings.notificationSound')}
</div>
<ToggleSwitch
checked={!!soundEnabled}
onChange={onToggleSound}
label={t('settings.notificationSound')}
/>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -153,6 +153,12 @@ const en = {
'shortcuts.searchMessages': 'Search messages',
// Send shortcut setting
'settings.title': 'Settings',
'settings.appearance': 'Appearance',
'settings.chat': 'Chat',
'settings.notifications': 'Notifications',
'settings.notificationSound': 'Notification sound',
'settings.language': 'Language',
'settings.sendShortcut': 'Send with',
'settings.sendEnter': 'Enter',
'settings.sendCtrlEnter': 'Ctrl+Enter',
@@ -304,6 +310,12 @@ const fr: Record<keyof typeof en, string> = {
'search.next': 'Résultat suivant',
'shortcuts.searchMessages': 'Rechercher dans les messages',
'settings.title': 'Paramètres',
'settings.appearance': 'Apparence',
'settings.chat': 'Chat',
'settings.notifications': 'Notifications',
'settings.notificationSound': 'Son de notification',
'settings.language': 'Langue',
'settings.sendShortcut': 'Envoyer avec',
'settings.sendEnter': 'Entrée',
'settings.sendCtrlEnter': 'Ctrl+Entrée',
@@ -454,6 +466,12 @@ const es: Record<keyof typeof en, string> = {
'search.next': 'Resultado siguiente',
'shortcuts.searchMessages': 'Buscar en mensajes',
'settings.title': 'Ajustes',
'settings.appearance': 'Apariencia',
'settings.chat': 'Chat',
'settings.notifications': 'Notificaciones',
'settings.notificationSound': 'Sonido de notificación',
'settings.language': 'Idioma',
'settings.sendShortcut': 'Enviar con',
'settings.sendEnter': 'Enter',
'settings.sendCtrlEnter': 'Ctrl+Enter',
@@ -606,6 +624,12 @@ const de: Record<keyof typeof en, string> = {
'search.next': 'Nächster Treffer',
'shortcuts.searchMessages': 'In Nachrichten suchen',
'settings.title': 'Einstellungen',
'settings.appearance': 'Darstellung',
'settings.chat': 'Chat',
'settings.notifications': 'Benachrichtigungen',
'settings.notificationSound': 'Benachrichtigungston',
'settings.language': 'Sprache',
'settings.sendShortcut': 'Senden mit',
'settings.sendEnter': 'Enter',
'settings.sendCtrlEnter': 'Strg+Enter',
@@ -756,6 +780,12 @@ const ja: Record<keyof typeof en, string> = {
'search.next': '次の結果',
'shortcuts.searchMessages': 'メッセージを検索',
'settings.title': '設定',
'settings.appearance': '外観',
'settings.chat': 'チャット',
'settings.notifications': '通知',
'settings.notificationSound': '通知音',
'settings.language': '言語',
'settings.sendShortcut': '送信キー',
'settings.sendEnter': 'Enter',
'settings.sendCtrlEnter': 'Ctrl+Enter',
@@ -906,6 +936,12 @@ const pt: Record<keyof typeof en, string> = {
'search.next': 'Próximo resultado',
'shortcuts.searchMessages': 'Pesquisar mensagens',
'settings.title': 'Configurações',
'settings.appearance': 'Aparência',
'settings.chat': 'Chat',
'settings.notifications': 'Notificações',
'settings.notificationSound': 'Som de notificação',
'settings.language': 'Idioma',
'settings.sendShortcut': 'Tecla de envio',
'settings.sendEnter': 'Enter',
'settings.sendCtrlEnter': 'Ctrl+Enter',
@@ -1056,6 +1092,12 @@ const zh: Record<keyof typeof en, string> = {
'search.next': '下一个',
'shortcuts.searchMessages': '搜索消息',
'settings.title': '设置',
'settings.appearance': '外观',
'settings.chat': '聊天',
'settings.notifications': '通知',
'settings.notificationSound': '通知声音',
'settings.language': '语言',
'settings.sendShortcut': '发送方式',
'settings.sendEnter': 'Enter',
'settings.sendCtrlEnter': 'Ctrl+Enter',
@@ -1206,6 +1248,12 @@ const it: Record<keyof typeof en, string> = {
'search.next': 'Risultato successivo',
'shortcuts.searchMessages': 'Cerca nei messaggi',
'settings.title': 'Impostazioni',
'settings.appearance': 'Aspetto',
'settings.chat': 'Chat',
'settings.notifications': 'Notifiche',
'settings.notificationSound': 'Suono di notifica',
'settings.language': 'Lingua',
'settings.sendShortcut': 'Invia con',
'settings.sendEnter': 'Invio',
'settings.sendCtrlEnter': 'Ctrl+Invio',