diff --git a/internal/api/handlers.go b/internal/api/handlers.go index ed2e79b..deda482 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -5,6 +5,7 @@ import ( "net/http" "os/exec" + "github.com/muyue/muyue/internal/config" "github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/mcp" "github.com/muyue/muyue/internal/scanner" @@ -174,6 +175,36 @@ func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"status": "ok"}) } +func (s *Server) handleUpdatePreferences(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + writeError(w, "PUT only", http.StatusMethodNotAllowed) + return + } + if s.config == nil { + writeError(w, "no config", http.StatusNotFound) + return + } + var body struct { + Language string `json:"language"` + KeyboardLayout string `json:"keyboard_layout"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.Language != "" { + s.config.Profile.Preferences.Language = body.Language + } + if body.KeyboardLayout != "" { + s.config.Profile.Preferences.KeyboardLayout = body.KeyboardLayout + } + if err := config.Save(s.config); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "ok"}) +} + func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { writeError(w, "POST only", http.StatusMethodNotAllowed) diff --git a/internal/api/server.go b/internal/api/server.go index a171d7c..21644f4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -35,6 +35,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/updates", s.handleUpdates) s.mux.HandleFunc("/api/install", s.handleInstall) s.mux.HandleFunc("/api/scan", s.handleScan) + s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences) s.mux.HandleFunc("/api/terminal", s.handleTerminal) s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure) } @@ -42,7 +43,7 @@ func (s *Server) routes() { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) diff --git a/internal/config/config.go b/internal/config/config.go index f1d95a1..1ebd73d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,12 +15,14 @@ type Profile struct { Email string `yaml:"email"` Languages []string `yaml:"languages"` Preferences struct { - Editor string `yaml:"editor"` - Shell string `yaml:"shell"` - Theme string `yaml:"theme"` - DefaultAI string `yaml:"default_ai"` - AutoUpdate bool `yaml:"auto_update"` - CheckOnStart bool `yaml:"check_on_start"` + Editor string `yaml:"editor"` + Shell string `yaml:"shell"` + Theme string `yaml:"theme"` + DefaultAI string `yaml:"default_ai"` + AutoUpdate bool `yaml:"auto_update"` + CheckOnStart bool `yaml:"check_on_start"` + Language string `yaml:"language"` + KeyboardLayout string `yaml:"keyboard_layout"` } `yaml:"preferences"` } @@ -179,6 +181,8 @@ func Default() *MuyueConfig { cfg.Profile.Preferences.AutoUpdate = true cfg.Profile.Preferences.CheckOnStart = true cfg.Profile.Preferences.Theme = "charm" + cfg.Profile.Preferences.Language = "fr" + cfg.Profile.Preferences.KeyboardLayout = "azerty" cfg.AI.Providers = []AIProvider{ { diff --git a/web/src/api/client.js b/web/src/api/client.js index cbeb187..8470963 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -25,6 +25,7 @@ const api = { runScan: () => request('/scan', { method: 'POST' }), installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }), configureMCP: () => request('/mcp/configure', { method: 'POST' }), + savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }), runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }), } diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index d86c2af..ef0f674 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -1,24 +1,26 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import api from '../api/client' import { getTheme, getThemeNames, applyTheme } from '../themes' +import { useI18n } from '../i18n' import Dashboard from './Dashboard' import Studio from './Studio' import Shell from './Shell' import Config from './Config' -const TABS = [ - { id: 'dash', label: 'Dashboard', icon: '\u25A0' }, - { id: 'studio', label: 'Studio', icon: '\u27E8\u27E9' }, - { id: 'shell', label: 'Shell', icon: '$' }, - { id: 'config', label: 'Config', icon: '\u2699' }, -] - export default function App() { const [activeTab, setActiveTab] = useState('dash') const [info, setInfo] = useState({}) const [clock, setClock] = useState(new Date()) const [updates, setUpdates] = useState([]) const [tools, setTools] = useState([]) + const { t, layout } = useI18n() + + const TABS = useMemo(() => [ + { id: 'dash', label: t('tabs.dashboard'), icon: '\u25A0' }, + { id: 'studio', label: t('tabs.studio'), icon: '\u27E8\u27E9' }, + { id: 'shell', label: t('tabs.shell'), icon: '$' }, + { id: 'config', label: t('tabs.config'), icon: '\u2699' }, + ], [t]) useEffect(() => { api.getInfo().then(setInfo).catch(() => {}) @@ -35,10 +37,16 @@ export default function App() { useEffect(() => { const onKey = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return - const map = { '1': 'dash', '2': 'studio', '3': 'shell', '4': 'config' } - if (map[e.key]) { + if (!e.ctrlKey && !e.metaKey) return + const map = { + Digit1: 'dash', + Digit2: 'studio', + Digit3: 'shell', + Digit4: 'config', + } + if (map[e.code]) { e.preventDefault() - setActiveTab(map[e.key]) + setActiveTab(map[e.code]) } } window.addEventListener('keydown', onKey) @@ -50,6 +58,25 @@ export default function App() { const hasUpdates = updates.some(u => u.needsUpdate) const installed = tools.filter(t => t.installed).length + const WINDOW_SHORTCUTS = useMemo(() => ({ + dash: [ + { keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') }, + ], + studio: [ + { keys: layout.keys.enter, desc: t('statusbar.sendMessage') }, + { keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') }, + { keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') }, + ], + shell: [ + { keys: layout.keys.enter, desc: t('statusbar.runCommand') }, + { keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') }, + { keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') }, + ], + config: [ + { keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') }, + ], + }), [layout, t]) + const renderContent = () => { switch (activeTab) { case 'dash': return setTools(t)} /> @@ -86,28 +113,43 @@ export default function App() {
- 0 ? 'ok' : 'off'}`} title={`${installed} tools installed`} /> - + 0 ? 'ok' : 'off'}`} + title={t('header.toolsInstalled', { count: installed })} + /> +
- {clock.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} + {clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })} -
+
{renderContent()}
- Press 1-4 to switch tabs +
- {hasUpdates && Updates available} - v{info.version || '...'} + + {layout.keys.ctrl}+{layout.keys.range} {t('statusbar.switchWindow')} +
) } + +function FooterShortcuts({ shortcuts }) { + return shortcuts.map((s, i) => ( + + {s.keys} {s.desc} + + )) +} diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index 749dee0..8e38a1a 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -1,7 +1,10 @@ import { useState, useEffect } from 'react' import { getThemeNames, applyTheme, getTheme } from '../themes' +import { useI18n, LANGUAGES } from '../i18n' +import { getLayoutList } from '../i18n/keyboards' export default function Config({ api }) { + const { t, language, keyboard, setLanguage, setKeyboard, layout } = useI18n() const [config, setConfig] = useState(null) const [providers, setProviders] = useState([]) const [skillList, setSkillList] = useState([]) @@ -14,6 +17,7 @@ export default function Config({ api }) { }, []) const themes = getThemeNames() + const layouts = getLayoutList() const handleThemeChange = (themeId) => { applyTheme(getTheme(themeId)) @@ -30,35 +34,65 @@ export default function Config({ api }) { return (
-
Profile
+
{t('config.profile')}
{config?.profile ? (
- - - - - - - + + + + + + +
) : ( -
Loading profile...
+
{t('config.loadingProfile')}
)}
-
AI Providers
+
{t('config.language')}
+
+ {LANGUAGES.map(lang => ( +
setLanguage(lang.id)} + > + {lang.name} +
+ ))} +
+
+ +
+
{t('config.keyboardLayout')}
+
+ {layouts.map(l => ( +
setKeyboard(l.id)} + > + {l.name} +
+ ))} +
+
+ +
+
{t('config.aiProviders')}
{providers.map((p, i) => (
{p.name} - {p.active && Active} + {p.active && {t('config.active')}}
{p.model} - {p.apiKey ? 'Key configured' : 'No key'} + {p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
@@ -67,32 +101,32 @@ export default function Config({ api }) {
-
Theme
+
{t('config.theme')}
- {themes.map(t => ( + {themes.map(th => (
handleThemeChange(t.id)} - title={t.name} + key={th.id} + className={`theme-swatch ${currentTheme === th.id ? 'active' : ''}`} + style={{ background: themeColors[th.id] || '#FF0033' }} + onClick={() => handleThemeChange(th.id)} + title={th.name} /> ))}
-
Skills ({skillList.length})
+
{t('config.skills')} ({skillList.length})
{skillList.length === 0 ? (
- No skills installed. - Run muyue skills init + {t('config.noSkills')} + {t('config.runSkillsInit')}
) : ( skillList.map((s, i) => (
{s.name} - {s.target || 'both'} + {s.target || 'both'} {s.description}
)) @@ -106,7 +140,7 @@ function FieldRow({ label, value }) { return (
{label} - {value || 'Not set'} + {value || '—'}
) } diff --git a/web/src/components/Dashboard.jsx b/web/src/components/Dashboard.jsx index 0222079..c8b35ea 100644 --- a/web/src/components/Dashboard.jsx +++ b/web/src/components/Dashboard.jsx @@ -1,126 +1,101 @@ import { useState } from 'react' +import { useI18n } from '../i18n' export default function Dashboard({ tools, updates, api, onRescan }) { - const [installing, setInstalling] = useState(false) - const [log, setLog] = useState([]) + const { t, layout } = useI18n() + const [activeSection, setActiveSection] = useState('tools') + const [notifications, setNotifications] = useState([]) - const installed = tools.filter(t => t.installed).length + const installed = tools.filter(tool => tool.installed).length const total = tools.length - const pct = total > 0 ? Math.round((installed / total) * 100) : 0 - const missing = tools.filter(t => !t.installed).map(t => t.name || t.Name) - const handleInstall = async () => { - if (missing.length === 0) return - setInstalling(true) - addLog(`Installing ${missing.length} tools...`, 'info') - try { - await api.installTools(missing) - addLog('Install started. Rescanning...', 'ok') - await api.runScan() - const data = await api.getTools() - onRescan(data.tools || []) - addLog('Done.', 'ok') - } catch (err) { - addLog(err.message, 'error') - } - setInstalling(false) + const addNotif = (text, type) => { + setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev]) } - const handleScan = async () => { - addLog('Scanning system...', 'info') - await api.runScan() - const data = await api.getTools() - onRescan(data.tools || []) - addLog('Scan complete.', 'ok') - } - - const handleCheckUpdates = async () => { - const data = await api.getUpdates().catch(() => ({ updates: [] })) - const count = (data.updates || []).filter(u => u.needsUpdate).length - addLog(count > 0 ? `${count} updates available.` : 'All tools up to date.', count > 0 ? 'warn' : 'ok') - } - - const addLog = (text, type) => setLog(prev => [...prev, { text, type, id: Date.now() }]) + const sections = [ + { id: 'tools', label: t('dashboard.systemOverview') }, + { id: 'notifications', label: t('dashboard.activityLog') }, + { id: 'workflows', label: t('studio.workflows') }, + ] return ( -
-
-
-
- System Overview — {installed}/{total} tools ({pct}%) +
+
+ {sections.map(s => ( +
setActiveSection(s.id)} + > + {s.label} + {s.id === 'tools' && total > 0 && ( + {installed}/{total} + )} + {s.id === 'notifications' && notifications.length > 0 && ( + {notifications.length} + )}
-
-
-
-
- {tools.map((t, i) => { - const name = t.name || t.Name - const ver = extractVersion(t.Version || t.version) - return ( -
- - {t.installed ? 'Installed' : 'Missing'} - - {name} - {ver && {ver}} -
- ) - })} -
-
+ ))}
-
-
-
Quick Actions
-
- - - - +
+ {activeSection === 'tools' && ( +
+ {tools.length === 0 ? ( +
{t('dashboard.noUpdateData')}
+ ) : ( +
+ {tools.map((tool, i) => { + const name = tool.name || tool.Name + const ver = extractVersion(tool.Version || tool.version) + return ( +
+ + {tool.installed ? '\u2713' : '\u2717'} + + {name} + {ver && {ver}} + {tool.installed && {t('dashboard.installed')}} +
+ ) + })} +
+ )}
-
+ )} -
-
Updates
- {updates.length === 0 ? ( -
No update data yet.
- ) : ( - updates.map((u, i) => ( -
- - {u.needsUpdate ? 'Update' : 'Latest'} - - {u.tool} - {u.needsUpdate && ( - - {u.current} → {u.latest} + {activeSection === 'notifications' && ( +
+ {notifications.length === 0 ? ( +
{t('dashboard.noUpdateData')}
+ ) : ( + notifications.map(n => ( +
+ + {n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })} - )} -
- )) - )} -
+ {n.text} +
+ )) + )} +
+ )} - {log.length > 0 && ( -
-
Activity Log
- {log.map(entry => ( -
- {entry.text} + {activeSection === 'workflows' && ( +
+
+
{t('studio.workflows')}
+
+ {t('studio.noWorkflow')}
- ))} +
+
+
{t('studio.activeAgents')}
+
+ {t('studio.noWorkflow')} +
+
)}
diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index f309ff1..77a9fe4 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -1,12 +1,14 @@ import { useState, useRef, useEffect } from 'react' +import { useI18n } from '../i18n' export default function Shell({ api }) { + const { t } = useI18n() const [history, setHistory] = useState([]) const [input, setInput] = useState('') const [cwd, setCwd] = useState('~') const [showAi, setShowAi] = useState(false) const [aiMessages, setAiMessages] = useState([ - { role: 'ai', content: 'I know your system inside out. Ask me anything.' } + { role: 'ai', content: t('shell.aiWelcome') } ]) const [aiInput, setAiInput] = useState('') const [aiLoading, setAiLoading] = useState(false) @@ -68,9 +70,9 @@ export default function Shell({ api }) { try { const res = await api.runCommand(`echo "AI: ${text}"`, '') - setAiMessages(prev => [...prev, { role: 'ai', content: res.output || 'No response' }]) + setAiMessages(prev => [...prev, { role: 'ai', content: res.output || t('shell.noResponse') }]) } catch (err) { - setAiMessages(prev => [...prev, { role: 'ai', content: `Error: ${err.message}` }]) + setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }]) } setAiLoading(false) } @@ -80,11 +82,11 @@ export default function Shell({ api }) {
- Terminal + {t('shell.terminal')} {cwd}
@@ -110,7 +112,7 @@ export default function Shell({ api }) { {showAi && (
-
AI Assistant
+
{t('shell.aiAssistant')}
{aiMessages.map((msg, i) => (
@@ -124,9 +126,9 @@ export default function Shell({ api }) { value={aiInput} onChange={e => setAiInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAiSend()} - placeholder="Ask AI..." + placeholder={t('shell.askAi')} /> - +
)} diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index adc62fa..9965c20 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -1,9 +1,11 @@ import { useState, useRef, useEffect } from 'react' +import { useI18n } from '../i18n' export default function Studio({ api }) { + const { t, layout } = useI18n() const [messages, setMessages] = useState([ - { role: 'ai', content: 'Welcome to Studio! Chat with your AI assistant here.' }, - { role: 'ai', content: 'Configure agents and workflows from the sidebar.' }, + { role: 'ai', content: t('studio.welcome') }, + { role: 'ai', content: t('studio.configureHint') }, ]) const [input, setInput] = useState('') const [sidebarPanel, setSidebarPanel] = useState('chat') @@ -23,10 +25,10 @@ export default function Studio({ api }) { api.runCommand(`echo "AI response simulation for: ${text}"`, '') .then(res => { - setMessages(prev => [...prev, { role: 'ai', content: res.output || res.error || 'No response' }]) + setMessages(prev => [...prev, { role: 'ai', content: res.output || res.error || t('studio.noResponse') }]) }) .catch(err => { - setMessages(prev => [...prev, { role: 'ai', content: `Error: ${err.message}` }]) + setMessages(prev => [...prev, { role: 'ai', content: `${t('studio.error')}: ${err.message}` }]) }) .finally(() => setLoading(false)) } @@ -39,9 +41,9 @@ export default function Studio({ api }) { } const sidebarItems = [ - { id: 'chat', label: 'Chat', icon: '#' }, - { id: 'agents', label: 'Agents', icon: '*' }, - { id: 'workflows', label: 'Workflows', icon: '~' }, + { id: 'chat', label: t('studio.chat'), icon: '#' }, + { id: 'agents', label: t('studio.agents'), icon: '*' }, + { id: 'workflows', label: t('studio.workflows'), icon: '~' }, ] return ( @@ -49,7 +51,7 @@ export default function Studio({ api }) {
- Chat + {t('studio.chat')} {loading && }
@@ -68,11 +70,11 @@ export default function Studio({ api }) { value={input} onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Type a message... (Enter to send)" + placeholder={t('studio.placeholder')} disabled={loading} />
@@ -93,42 +95,42 @@ export default function Studio({ api }) { {sidebarPanel === 'chat' && (
-
Commands
+
{t('studio.commands')}
- /plan <goal>
- /help + {t('studio.planGoal')}
+ {t('studio.help')}
)} {sidebarPanel === 'agents' && (
-
Active Agents
+
{t('studio.activeAgents')}
C
-
Crush
-
Stopped
+
{t('studio.crush')}
+
{t('studio.stopped')}
- Inactive + {t('studio.inactive')}
CC
-
Claude Code
-
Stopped
+
{t('studio.claudeCode')}
+
{t('studio.stopped')}
- Inactive + {t('studio.inactive')}
)} {sidebarPanel === 'workflows' && (
-
Workflows
+
{t('studio.workflows')}
- No active workflow. - Use /plan <goal> in chat to start. + {t('studio.noWorkflow')} + {t('studio.usePlan')}
)} diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js new file mode 100644 index 0000000..50ad835 --- /dev/null +++ b/web/src/i18n/en.js @@ -0,0 +1,105 @@ +const en = { + tabs: { + dashboard: 'Dashboard', + studio: 'Studio', + shell: 'Shell', + config: 'Config', + }, + + header: { + toolsInstalled: '{count} tools installed', + updatesAvailable: 'Updates available', + upToDate: 'Up to date', + }, + + statusbar: { + switchWindow: 'Switch window', + sendMessage: 'Send message', + newLine: 'New line', + runCommand: 'Run command', + commandHistory: 'Command history', + }, + + dashboard: { + systemOverview: 'System Overview', + tools: 'tools', + installed: 'Installed', + missing: 'Missing', + quickActions: 'Quick Actions', + installMissing: 'Install missing', + checkUpdates: 'Check for updates', + rescanSystem: 'Rescan system', + configureMCP: 'Configure MCP', + updates: 'Updates', + update: 'Update', + latest: 'Latest', + activityLog: 'Activity Log', + noUpdateData: 'No update data yet.', + installing: 'Installing {count} tools...', + installStarted: 'Install started. Rescanning...', + done: 'Done.', + scanComplete: 'Scan complete.', + updatesCount: '{count} updates available.', + allUpToDate: 'All tools up to date.', + mcpConfigured: 'MCP configured.', + }, + + studio: { + welcome: 'Welcome to Studio! Chat with your AI assistant here.', + configureHint: 'Configure agents and workflows from the sidebar.', + chat: 'Chat', + agents: 'Agents', + workflows: 'Workflows', + placeholder: 'Type a message... (Enter to send)', + send: 'Send', + commands: 'Commands', + planGoal: '/plan ', + help: '/help', + activeAgents: 'Active Agents', + crush: 'Crush', + claudeCode: 'Claude Code', + stopped: 'Stopped', + inactive: 'Inactive', + noWorkflow: 'No active workflow.', + usePlan: 'Use /plan in chat to start.', + noResponse: 'No response', + error: 'Error', + }, + + shell: { + terminal: 'Terminal', + hideAi: 'Hide AI', + aiAssistant: 'AI Assistant', + aiWelcome: 'I know your system inside out. Ask me anything.', + askAi: 'Ask AI...', + send: 'Send', + noResponse: 'No response', + error: 'Error', + }, + + config: { + profile: 'Profile', + name: 'Name', + pseudo: 'Pseudo', + email: 'Email', + editor: 'Editor', + shell: 'Shell', + defaultAi: 'Default AI', + languages: 'Languages', + loadingProfile: 'Loading profile...', + notSet: 'Not set', + aiProviders: 'AI Providers', + active: 'Active', + keyConfigured: 'Key configured', + noKey: 'No key', + theme: 'Theme', + skills: 'Skills', + noSkills: 'No skills installed.', + runSkillsInit: 'Run muyue skills init', + language: 'Language', + keyboardLayout: 'Keyboard Layout', + target: 'Target', + }, +} + +export default en diff --git a/web/src/i18n/fr.js b/web/src/i18n/fr.js new file mode 100644 index 0000000..24c285c --- /dev/null +++ b/web/src/i18n/fr.js @@ -0,0 +1,105 @@ +const fr = { + tabs: { + dashboard: 'Tableau de bord', + studio: 'Studio', + shell: 'Terminal', + config: 'Configuration', + }, + + header: { + toolsInstalled: '{count} outils install\u00e9s', + updatesAvailable: 'Mises \u00e0 jour disponibles', + upToDate: '\u00c0 jour', + }, + + statusbar: { + switchWindow: 'Changer de fen\u00eatre', + sendMessage: 'Envoyer le message', + newLine: 'Nouvelle ligne', + runCommand: 'Ex\u00e9cuter', + commandHistory: 'Historique', + }, + + dashboard: { + systemOverview: 'Vue d\u2019ensemble du syst\u00e8me', + tools: 'outils', + installed: 'Install\u00e9', + missing: 'Manquant', + quickActions: 'Actions rapides', + installMissing: 'Installer les manquants', + checkUpdates: 'V\u00e9rifier les mises \u00e0 jour', + rescanSystem: 'Rescanner le syst\u00e8me', + configureMCP: 'Configurer MCP', + updates: 'Mises \u00e0 jour', + update: 'Mise \u00e0 jour', + latest: '\u00c0 jour', + activityLog: 'Journal d\u2019activit\u00e9', + noUpdateData: 'Aucune donn\u00e9e de mise \u00e0 jour.', + installing: 'Installation de {count} outils...', + installStarted: 'Installation lanc\u00e9e. Rescan en cours...', + done: 'Termin\u00e9.', + scanComplete: 'Scan termin\u00e9.', + updatesCount: '{count} mises \u00e0 jour disponibles.', + allUpToDate: 'Tous les outils sont \u00e0 jour.', + mcpConfigured: 'MCP configur\u00e9.', + }, + + studio: { + welcome: 'Bienvenue dans Studio ! Discutez avec votre assistant IA ici.', + configureHint: 'Configurez les agents et workflows depuis la barre lat\u00e9rale.', + chat: 'Chat', + agents: 'Agents', + workflows: 'Workflows', + placeholder: 'Tapez un message... (Entr\u00e9e pour envoyer)', + send: 'Envoyer', + commands: 'Commandes', + planGoal: '/plan ', + help: '/help', + activeAgents: 'Agents actifs', + crush: 'Crush', + claudeCode: 'Claude Code', + stopped: 'Arr\u00eat\u00e9', + inactive: 'Inactif', + noWorkflow: 'Aucun workflow actif.', + usePlan: 'Utilisez /plan dans le chat pour d\u00e9marrer.', + noResponse: 'Pas de r\u00e9ponse', + error: 'Erreur', + }, + + shell: { + terminal: 'Terminal', + hideAi: 'Masquer IA', + aiAssistant: 'Assistant IA', + aiWelcome: 'Je connais votre syst\u00e8me sur le bout des doigts. Demandez-moi n\u2019importe quoi.', + askAi: 'Demander \u00e0 l\u2019IA...', + send: 'Envoyer', + noResponse: 'Pas de r\u00e9ponse', + error: 'Erreur', + }, + + config: { + profile: 'Profil', + name: 'Nom', + pseudo: 'Pseudo', + email: 'Email', + editor: '\u00c9diteur', + shell: 'Shell', + defaultAi: 'IA par d\u00e9faut', + languages: 'Langages', + loadingProfile: 'Chargement du profil...', + notSet: 'Non d\u00e9fini', + aiProviders: 'Fournisseurs IA', + active: 'Actif', + keyConfigured: 'Cl\u00e9 configur\u00e9e', + noKey: 'Pas de cl\u00e9', + theme: 'Th\u00e8me', + skills: 'Comp\u00e9tences', + noSkills: 'Aucune comp\u00e9tence install\u00e9e.', + runSkillsInit: 'Ex\u00e9cutez muyue skills init', + language: 'Langue', + keyboardLayout: 'Disposition du clavier', + target: 'Cible', + }, +} + +export default fr diff --git a/web/src/i18n/index.jsx b/web/src/i18n/index.jsx new file mode 100644 index 0000000..e746323 --- /dev/null +++ b/web/src/i18n/index.jsx @@ -0,0 +1,101 @@ +import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react' +import en from './en' +import fr from './fr' +import { getLayout, getLayoutList } from './keyboards' +import api from '../api/client' + +const translations = { en, fr } + +const STORAGE_KEY_LANG = 'muyue-language' +const STORAGE_KEY_KBD = 'muyue-keyboard' + +const I18nContext = createContext(null) + +function resolveLocale(layout) { + const l = getLayout(layout) + return l.locale +} + +export function I18nProvider({ children }) { + const [language, setLanguageState] = useState(() => localStorage.getItem(STORAGE_KEY_LANG) || 'fr') + const [keyboard, setKeyboardState] = useState(() => localStorage.getItem(STORAGE_KEY_KBD) || 'azerty') + const [loaded, setLoaded] = useState(false) + const pendingSave = useRef(null) + + useEffect(() => { + api.getConfig() + .then(d => { + const prefs = d.profile?.preferences + if (prefs?.language) setLanguageState(prefs.language) + if (prefs?.keyboard_layout) setKeyboardState(prefs.keyboard_layout) + }) + .catch(() => {}) + .finally(() => setLoaded(true)) + }, []) + + useEffect(() => { + if (!loaded) return + if (pendingSave.current) clearTimeout(pendingSave.current) + pendingSave.current = setTimeout(() => { + api.savePreferences({ language, keyboard_layout: keyboard }).catch(() => {}) + }, 500) + return () => { if (pendingSave.current) clearTimeout(pendingSave.current) } + }, [language, keyboard, loaded]) + + const setLanguage = useCallback((lang) => { + setLanguageState(lang) + localStorage.setItem(STORAGE_KEY_LANG, lang) + }, []) + + const setKeyboard = useCallback((kbd) => { + setKeyboardState(kbd) + localStorage.setItem(STORAGE_KEY_KBD, kbd) + }, []) + + const layout = useMemo(() => getLayout(keyboard), [keyboard]) + + const t = useCallback((key, params) => { + const dict = translations[language] || translations.fr + const keys = key.split('.') + let value = dict + for (const k of keys) { + if (value == null) return key + value = value[k] + } + if (typeof value !== 'string') return key + if (params) { + return Object.entries(params).reduce((str, [k, v]) => str.replace(`{${k}}`, v), value) + } + return value + }, [language]) + + const clockLocale = useMemo(() => resolveLocale(keyboard), [keyboard]) + + const contextValue = useMemo(() => ({ + language, + keyboard, + layout, + setLanguage, + setKeyboard, + t, + clockLocale, + layouts: getLayoutList(), + }), [language, keyboard, layout, t, clockLocale]) + + return ( + + {children} + + ) +} + +export function useI18n() { + const ctx = useContext(I18nContext) + if (!ctx) throw new Error('useI18n must be used within I18nProvider') + return ctx +} + +export const LANGUAGES = [ + { id: 'fr', name: 'Fran\u00e7ais' }, + { id: 'en', name: 'English' }, +] diff --git a/web/src/i18n/keyboards.js b/web/src/i18n/keyboards.js new file mode 100644 index 0000000..c64b4f4 --- /dev/null +++ b/web/src/i18n/keyboards.js @@ -0,0 +1,61 @@ +export const LAYOUTS = { + qwerty: { + id: 'qwerty', + name: 'QWERTY', + locale: 'en-US', + keys: { + tab1: '1', + tab2: '2', + tab3: '3', + tab4: '4', + ctrl: 'Ctrl', + enter: 'Enter', + shift: 'Shift', + up: '\u2191', + down: '\u2193', + range: '1-4', + }, + }, + azerty: { + id: 'azerty', + name: 'AZERTY', + locale: 'fr-FR', + keys: { + tab1: '&', + tab2: '\u00e9', + tab3: '"', + tab4: "'", + ctrl: 'Ctrl', + enter: 'Entr\u00e9e', + shift: 'Maj', + up: '\u2191', + down: '\u2193', + range: '&-\u00e9-"-\'', + }, + }, + qwertz: { + id: 'qwertz', + name: 'QWERTZ', + locale: 'de-DE', + keys: { + tab1: '1', + tab2: '2', + tab3: '3', + tab4: '4', + ctrl: 'Strg', + enter: 'Enter', + shift: 'Umschalt', + up: '\u2191', + down: '\u2193', + range: '1-4', + }, + }, +} + +export function getLayout(id) { + return LAYOUTS[id] || LAYOUTS.azerty +} + +export function getLayoutList() { + return Object.values(LAYOUTS) +} diff --git a/web/src/main.jsx b/web/src/main.jsx index 981f27d..c48c4c6 100644 --- a/web/src/main.jsx +++ b/web/src/main.jsx @@ -1,10 +1,13 @@ import React from 'react' import ReactDOM from 'react-dom/client' +import { I18nProvider } from './i18n' import './styles/global.css' import App from './components/App' ReactDOM.createRoot(document.getElementById('root')).render( - + + + ) diff --git a/web/src/styles/global.css b/web/src/styles/global.css index ecb6c4a..70df8d9 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -168,6 +168,12 @@ input::placeholder { color: var(--text-disabled); } color: var(--text-disabled); } .statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; } +.statusbar-shortcut { display: inline-flex; align-items: center; gap: 4px; } +.statusbar-shortcut kbd { + display: inline-block; padding: 1px 5px; border-radius: 3px; + background: var(--bg-card); border: 1px solid var(--border); + font-family: var(--font-mono); font-size: 10px; color: var(--text-tertiary); +} .card { background: var(--bg-card); @@ -328,6 +334,60 @@ input::placeholder { color: var(--text-disabled); } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; } +.dashboard-layout { display: flex; flex-direction: column; height: 100%; } +.dashboard-tabs { + display: flex; gap: 0; border-bottom: 1px solid var(--border); + background: var(--bg-surface); flex-shrink: 0; +} +.dashboard-tab { + padding: 10px 24px; font-size: 13px; font-weight: 600; + color: var(--text-tertiary); cursor: pointer; transition: all 0.15s; + display: flex; align-items: center; gap: 8px; border-bottom: 2px solid transparent; + user-select: none; +} +.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-card); } +.dashboard-tab.active { color: var(--accent); border-bottom-color: var(--accent); } +.tab-count { + font-size: 10px; padding: 1px 6px; border-radius: 99px; + background: var(--bg-card); color: var(--text-tertiary); font-family: var(--font-mono); +} +.tab-count.warn { background: rgba(255,215,64,0.15); color: var(--warning); } + +.dashboard-content { flex: 1; overflow-y: auto; } + +.dashboard-tools { padding: 16px 24px; } +.tools-compact { display: flex; flex-direction: column; gap: 2px; } +.tool-compact-row { + display: flex; align-items: center; gap: 10px; + padding: 6px 12px; border-radius: var(--radius); + font-size: 13px; transition: background 0.1s; +} +.tool-compact-row:hover { background: var(--bg-card); } +.badge.sm { padding: 1px 5px; font-size: 10px; } +.tool-compact-name { color: var(--text-primary); font-weight: 500; flex: 1; } +.tool-compact-ver { color: var(--text-tertiary); font-size: 11px; font-family: var(--font-mono); } +.tool-compact-installed { color: var(--success); font-size: 11px; font-family: var(--font-mono); opacity: 0.7; } + +.dashboard-notifications { padding: 16px 24px; } +.notif-row { + display: flex; align-items: flex-start; gap: 12px; + padding: 8px 12px; border-radius: var(--radius); margin-bottom: 4px; +} +.notif-row:hover { background: var(--bg-card); } +.notif-time { color: var(--text-disabled); font-size: 11px; font-family: var(--font-mono); flex-shrink: 0; padding-top: 1px; } +.notif-text { font-size: 13px; color: var(--text-secondary); } +.notif-info .notif-text { color: var(--info); } +.notif-ok .notif-text { color: var(--success); } +.notif-warn .notif-text { color: var(--warning); } +.notif-error .notif-text { color: var(--error); } + +.dashboard-workflows { padding: 16px 24px; display: flex; flex-direction: column; gap: 24px; } +.workflow-section { } +.section-label { + font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; + letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border); +} + .panel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--bg-surface);