feat(web): add i18n support with FR/EN locales and keyboard layout awareness
All checks were successful
Beta Release / beta (push) Successful in 36s
All checks were successful
Beta Release / beta (push) Successful in 36s
Add full internationalization system with React context, French/English translations, and AZERTY/QWERTY keyboard layout support. Dashboard now uses a tabbed layout (Tools, Notifications, Workflows). Config page exposes language and keyboard preferences persisted via new /api/preferences endpoint. 💕 Generated with Crush Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -21,6 +21,8 @@ type Profile struct {
|
||||
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{
|
||||
{
|
||||
|
||||
@@ -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 }) }),
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <Dashboard tools={tools} updates={updates} api={api} onRescan={t => setTools(t)} />
|
||||
@@ -86,28 +113,43 @@ export default function App() {
|
||||
<div className="header-spacer" />
|
||||
|
||||
<div className="header-indicators">
|
||||
<span className={`indicator ${installed > 0 ? 'ok' : 'off'}`} title={`${installed} tools installed`} />
|
||||
<span className={`indicator ${hasUpdates ? 'warn' : 'ok'}`} title={hasUpdates ? 'Updates available' : 'Up to date'} />
|
||||
<span
|
||||
className={`indicator ${installed > 0 ? 'ok' : 'off'}`}
|
||||
title={t('header.toolsInstalled', { count: installed })}
|
||||
/>
|
||||
<span
|
||||
className={`indicator ${hasUpdates ? 'warn' : 'ok'}`}
|
||||
title={hasUpdates ? t('header.updatesAvailable') : t('header.upToDate')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="header-clock">
|
||||
{clock.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
||||
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<main className="content fade-in" key={activeTab}>
|
||||
<main className="content fade-in" key={`${activeTab}-${TABS.length}`}>
|
||||
{renderContent()}
|
||||
</main>
|
||||
|
||||
<footer className="statusbar">
|
||||
<div className="statusbar-left">
|
||||
<span>Press 1-4 to switch tabs</span>
|
||||
<FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} />
|
||||
</div>
|
||||
<div className="statusbar-right">
|
||||
{hasUpdates && <span style={{ color: 'var(--warning)' }}>Updates available</span>}
|
||||
<span>v{info.version || '...'}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{layout.keys.ctrl}+{layout.keys.range} {t('statusbar.switchWindow')}
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterShortcuts({ shortcuts }) {
|
||||
return shortcuts.map((s, i) => (
|
||||
<span key={i} className="statusbar-shortcut">
|
||||
<kbd>{s.keys}</kbd> {s.desc}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="config-layout">
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">Profile</div>
|
||||
<div className="config-section-title">{t('config.profile')}</div>
|
||||
{config?.profile ? (
|
||||
<div>
|
||||
<FieldRow label="Name" value={config.profile.name} />
|
||||
<FieldRow label="Pseudo" value={config.profile.pseudo} />
|
||||
<FieldRow label="Email" value={config.profile.email} />
|
||||
<FieldRow label="Editor" value={config.profile.preferences?.editor} />
|
||||
<FieldRow label="Shell" value={config.profile.preferences?.shell} />
|
||||
<FieldRow label="Default AI" value={config.profile.preferences?.defaultAI} />
|
||||
<FieldRow label="Languages" value={config.profile.languages?.join(', ')} />
|
||||
<FieldRow label={t('config.name')} value={config.profile.name} />
|
||||
<FieldRow label={t('config.pseudo')} value={config.profile.pseudo} />
|
||||
<FieldRow label={t('config.email')} value={config.profile.email} />
|
||||
<FieldRow label={t('config.editor')} value={config.profile.preferences?.editor} />
|
||||
<FieldRow label={t('config.shell')} value={config.profile.preferences?.shell} />
|
||||
<FieldRow label={t('config.defaultAi')} value={config.profile.preferences?.defaultAI} />
|
||||
<FieldRow label={t('config.languages')} value={config.profile.languages?.join(', ')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">Loading profile...</div>
|
||||
<div className="empty-state">{t('config.loadingProfile')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">AI Providers</div>
|
||||
<div className="config-section-title">{t('config.language')}</div>
|
||||
<div className="actions-stack">
|
||||
{LANGUAGES.map(lang => (
|
||||
<div
|
||||
key={lang.id}
|
||||
className={`chip ${language === lang.id ? 'active' : ''}`}
|
||||
onClick={() => setLanguage(lang.id)}
|
||||
>
|
||||
{lang.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.keyboardLayout')}</div>
|
||||
<div className="actions-stack">
|
||||
{layouts.map(l => (
|
||||
<div
|
||||
key={l.id}
|
||||
className={`chip ${keyboard === l.id ? 'active' : ''}`}
|
||||
onClick={() => setKeyboard(l.id)}
|
||||
>
|
||||
{l.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.aiProviders')}</div>
|
||||
{providers.map((p, i) => (
|
||||
<div key={i} className="provider-card">
|
||||
<div className="provider-info">
|
||||
<div className="provider-name">
|
||||
{p.name}
|
||||
{p.active && <span className="badge accent" style={{ marginLeft: 8 }}>Active</span>}
|
||||
{p.active && <span className="badge accent" style={{ marginLeft: 8 }}>{t('config.active')}</span>}
|
||||
</div>
|
||||
<div className="provider-meta">
|
||||
<span>{p.model}</span>
|
||||
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
|
||||
{p.apiKey ? 'Key configured' : 'No key'}
|
||||
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,32 +101,32 @@ export default function Config({ api }) {
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">Theme</div>
|
||||
<div className="config-section-title">{t('config.theme')}</div>
|
||||
<div className="theme-picker">
|
||||
{themes.map(t => (
|
||||
{themes.map(th => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`theme-swatch ${currentTheme === t.id ? 'active' : ''}`}
|
||||
style={{ background: themeColors[t.id] || '#FF0033' }}
|
||||
onClick={() => 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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">Skills ({skillList.length})</div>
|
||||
<div className="config-section-title">{t('config.skills')} ({skillList.length})</div>
|
||||
{skillList.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
No skills installed.
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>Run muyue skills init</span>
|
||||
{t('config.noSkills')}
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
|
||||
</div>
|
||||
) : (
|
||||
skillList.map((s, i) => (
|
||||
<div key={i} className="tool-row">
|
||||
<span className="tool-name">{s.name}</span>
|
||||
<span className={`badge neutral`}>{s.target || 'both'}</span>
|
||||
<span className="badge neutral">{s.target || 'both'}</span>
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>{s.description}</span>
|
||||
</div>
|
||||
))
|
||||
@@ -106,7 +140,7 @@ function FieldRow({ label, value }) {
|
||||
return (
|
||||
<div className="field-row">
|
||||
<span className="field-label">{label}</span>
|
||||
<span className={`field-value ${!value ? 'empty' : ''}`}>{value || 'Not set'}</span>
|
||||
<span className={`field-value ${!value ? 'empty' : ''}`}>{value || '—'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid-2">
|
||||
<div style={{ overflow: 'auto' }}>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
System Overview — {installed}/{total} tools ({pct}%)
|
||||
<div className="dashboard-layout">
|
||||
<div className="dashboard-tabs">
|
||||
{sections.map(s => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`dashboard-tab ${activeSection === s.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveSection(s.id)}
|
||||
>
|
||||
{s.label}
|
||||
{s.id === 'tools' && total > 0 && (
|
||||
<span className="tab-count">{installed}/{total}</span>
|
||||
)}
|
||||
{s.id === 'notifications' && notifications.length > 0 && (
|
||||
<span className="tab-count warn">{notifications.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="progress" style={{ marginBottom: 16 }}>
|
||||
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
{tools.map((t, i) => {
|
||||
const name = t.name || t.Name
|
||||
const ver = extractVersion(t.Version || t.version)
|
||||
|
||||
<div className="dashboard-content">
|
||||
{activeSection === 'tools' && (
|
||||
<div className="dashboard-tools">
|
||||
{tools.length === 0 ? (
|
||||
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
|
||||
) : (
|
||||
<div className="tools-compact">
|
||||
{tools.map((tool, i) => {
|
||||
const name = tool.name || tool.Name
|
||||
const ver = extractVersion(tool.Version || tool.version)
|
||||
return (
|
||||
<div key={i} className="tool-row">
|
||||
<span className={`badge ${t.installed ? 'ok' : 'error'}`}>
|
||||
{t.installed ? 'Installed' : 'Missing'}
|
||||
<div key={i} className="tool-compact-row">
|
||||
<span className={`badge sm ${tool.installed ? 'ok' : 'error'}`}>
|
||||
{tool.installed ? '\u2713' : '\u2717'}
|
||||
</span>
|
||||
<span className="tool-name">{name}</span>
|
||||
{ver && <span className="tool-version">{ver}</span>}
|
||||
<span className="tool-compact-name">{name}</span>
|
||||
{ver && <span className="tool-compact-ver">{ver}</span>}
|
||||
{tool.installed && <span className="tool-compact-installed">{t('dashboard.installed')}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div className="card">
|
||||
<div className="card-header">Quick Actions</div>
|
||||
<div className="actions-stack">
|
||||
<button onClick={handleInstall} disabled={installing || missing.length === 0}>
|
||||
{installing && <span className="spinner" />}
|
||||
Install missing ({missing.length})
|
||||
</button>
|
||||
<button onClick={handleCheckUpdates}>Check for updates</button>
|
||||
<button onClick={handleScan}>Rescan system</button>
|
||||
<button onClick={() => api.configureMCP().then(() => addLog('MCP configured.', 'ok'))}>
|
||||
Configure MCP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ flex: 1 }}>
|
||||
<div className="card-header">Updates</div>
|
||||
{updates.length === 0 ? (
|
||||
<div className="empty-state">No update data yet.</div>
|
||||
) : (
|
||||
updates.map((u, i) => (
|
||||
<div key={i} className="tool-row">
|
||||
<span className={`badge ${u.needsUpdate ? 'warn' : 'ok'}`}>
|
||||
{u.needsUpdate ? 'Update' : 'Latest'}
|
||||
</span>
|
||||
<span className="tool-name">{u.tool}</span>
|
||||
{u.needsUpdate && (
|
||||
<span style={{ color: 'var(--warning)', fontSize: 12, fontFamily: 'var(--font-mono)' }}>
|
||||
{u.current} → {u.latest}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'notifications' && (
|
||||
<div className="dashboard-notifications">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
|
||||
) : (
|
||||
notifications.map(n => (
|
||||
<div key={n.id} className={`notif-row notif-${n.type}`}>
|
||||
<span className="notif-time">
|
||||
{n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
<span className="notif-text">{n.text}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-header">Activity Log</div>
|
||||
{log.map(entry => (
|
||||
<div key={entry.id} style={{
|
||||
fontSize: 12,
|
||||
padding: '4px 0',
|
||||
color: entry.type === 'error' ? 'var(--error)' :
|
||||
entry.type === 'warn' ? 'var(--warning)' :
|
||||
entry.type === 'ok' ? 'var(--success)' : 'var(--text-tertiary)',
|
||||
}}>
|
||||
{entry.text}
|
||||
{activeSection === 'workflows' && (
|
||||
<div className="dashboard-workflows">
|
||||
<div className="workflow-section">
|
||||
<div className="section-label">{t('studio.workflows')}</div>
|
||||
<div className="empty-state" style={{ padding: 20 }}>
|
||||
{t('studio.noWorkflow')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="workflow-section">
|
||||
<div className="section-label">{t('studio.activeAgents')}</div>
|
||||
<div className="empty-state" style={{ padding: 20 }}>
|
||||
{t('studio.noWorkflow')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 }) {
|
||||
<div className="terminal" style={{ flex: 1 }}>
|
||||
<div className="panel-header">
|
||||
<span className="panel-title">
|
||||
Terminal
|
||||
{t('shell.terminal')}
|
||||
<span className="panel-subtitle">{cwd}</span>
|
||||
</span>
|
||||
<button className="ghost sm" onClick={() => setShowAi(!showAi)}>
|
||||
{showAi ? 'Hide AI' : 'AI Assistant'}
|
||||
{showAi ? t('shell.hideAi') : t('shell.aiAssistant')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +112,7 @@ export default function Shell({ api }) {
|
||||
|
||||
{showAi && (
|
||||
<div className="ai-panel">
|
||||
<div className="ai-panel-header">AI Assistant</div>
|
||||
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
|
||||
<div className="ai-panel-messages">
|
||||
{aiMessages.map((msg, i) => (
|
||||
<div key={i} className={`ai-message ${msg.role}`}>
|
||||
@@ -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')}
|
||||
/>
|
||||
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>Send</button>
|
||||
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 }) {
|
||||
<div className="chat-layout" style={{ flex: 1, borderRight: '1px solid var(--border)' }}>
|
||||
<div className="panel-header">
|
||||
<span className="panel-title">
|
||||
Chat
|
||||
{t('studio.chat')}
|
||||
{loading && <span className="spinner" />}
|
||||
</span>
|
||||
</div>
|
||||
@@ -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}
|
||||
/>
|
||||
<button className="primary" onClick={handleSend} disabled={loading || !input.trim()}>
|
||||
Send
|
||||
{t('studio.send')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,42 +95,42 @@ export default function Studio({ api }) {
|
||||
|
||||
{sidebarPanel === 'chat' && (
|
||||
<div>
|
||||
<div className="section-title">Commands</div>
|
||||
<div className="section-title">{t('studio.commands')}</div>
|
||||
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text-tertiary)' }}>
|
||||
/plan <goal><br />
|
||||
/help
|
||||
{t('studio.planGoal')}<br />
|
||||
{t('studio.help')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sidebarPanel === 'agents' && (
|
||||
<div>
|
||||
<div className="section-title">Active Agents</div>
|
||||
<div className="section-title">{t('studio.activeAgents')}</div>
|
||||
<div className="agent-card">
|
||||
<div className="agent-avatar">C</div>
|
||||
<div>
|
||||
<div className="agent-name">Crush</div>
|
||||
<div className="agent-status">Stopped</div>
|
||||
<div className="agent-name">{t('studio.crush')}</div>
|
||||
<div className="agent-status">{t('studio.stopped')}</div>
|
||||
</div>
|
||||
<span className="badge neutral" style={{ marginLeft: 'auto' }}>Inactive</span>
|
||||
<span className="badge neutral" style={{ marginLeft: 'auto' }}>{t('studio.inactive')}</span>
|
||||
</div>
|
||||
<div className="agent-card">
|
||||
<div className="agent-avatar">CC</div>
|
||||
<div>
|
||||
<div className="agent-name">Claude Code</div>
|
||||
<div className="agent-status">Stopped</div>
|
||||
<div className="agent-name">{t('studio.claudeCode')}</div>
|
||||
<div className="agent-status">{t('studio.stopped')}</div>
|
||||
</div>
|
||||
<span className="badge neutral" style={{ marginLeft: 'auto' }}>Inactive</span>
|
||||
<span className="badge neutral" style={{ marginLeft: 'auto' }}>{t('studio.inactive')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sidebarPanel === 'workflows' && (
|
||||
<div>
|
||||
<div className="section-title">Workflows</div>
|
||||
<div className="section-title">{t('studio.workflows')}</div>
|
||||
<div className="empty-state">
|
||||
No active workflow.
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>Use /plan <goal> in chat to start.</span>
|
||||
{t('studio.noWorkflow')}
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('studio.usePlan')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
105
web/src/i18n/en.js
Normal file
105
web/src/i18n/en.js
Normal file
@@ -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 <goal>',
|
||||
help: '/help',
|
||||
activeAgents: 'Active Agents',
|
||||
crush: 'Crush',
|
||||
claudeCode: 'Claude Code',
|
||||
stopped: 'Stopped',
|
||||
inactive: 'Inactive',
|
||||
noWorkflow: 'No active workflow.',
|
||||
usePlan: 'Use /plan <goal> 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
|
||||
105
web/src/i18n/fr.js
Normal file
105
web/src/i18n/fr.js
Normal file
@@ -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 <objectif>',
|
||||
help: '/help',
|
||||
activeAgents: 'Agents actifs',
|
||||
crush: 'Crush',
|
||||
claudeCode: 'Claude Code',
|
||||
stopped: 'Arr\u00eat\u00e9',
|
||||
inactive: 'Inactif',
|
||||
noWorkflow: 'Aucun workflow actif.',
|
||||
usePlan: 'Utilisez /plan <objectif> 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
|
||||
101
web/src/i18n/index.jsx
Normal file
101
web/src/i18n/index.jsx
Normal file
@@ -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 (
|
||||
<I18nContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
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' },
|
||||
]
|
||||
61
web/src/i18n/keyboards.js
Normal file
61
web/src/i18n/keyboards.js
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<I18nProvider>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user