diff --git a/web/dist/index.html b/web/dist/index.html index 7aa9b8743..f928de5df 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -5,8 +5,8 @@ ZeroClaw - - + +
diff --git a/web/e2e/dashboard.spec.ts b/web/e2e/dashboard.spec.ts new file mode 100644 index 000000000..c7629d8ca --- /dev/null +++ b/web/e2e/dashboard.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem('zeroclaw_token', 'test-token'); + window.localStorage.setItem('zeroclaw:locale', 'en'); + }); +}); + +test('dashboard end-to-end flow stays healthy', async ({ page }) => { + await page.goto('/_app/'); + await expect(page.getByText('Electric Runtime Dashboard')).toBeVisible(); + + await page.goto('/_app/agent'); + await expect(page.getByPlaceholder('Type a message...')).toBeVisible(); + await page.getByTestId('chat-input').fill('hello from e2e'); + await page.keyboard.press('Enter'); + await expect(page.getByText('Echo: hello from e2e')).toBeVisible(); + + await page.goto('/_app/tools'); + await page.getByRole('button', { name: /shell/i }).first().click(); + await expect(page.getByText('Parameter Schema')).toBeVisible(); + + await page.goto('/_app/cron'); + await page.getByRole('button', { name: 'Add Job' }).click(); + await page.getByPlaceholder('e.g. Daily cleanup').fill('Morning job'); + await page.getByPlaceholder('e.g. 0 0 * * * (cron expression)').fill('0 8 * * *'); + await page.getByPlaceholder('e.g. cleanup --older-than 7d').fill('sync --morning'); + await page.getByRole('button', { name: 'Add Job' }).last().click(); + await expect(page.getByText('Morning job')).toBeVisible(); + + await page.goto('/_app/integrations'); + await expect(page.getByText('Discord')).toBeVisible(); + + await page.goto('/_app/memory'); + await page.getByRole('button', { name: 'Add Memory' }).click(); + await page.getByPlaceholder('e.g. user_preferences').fill('favorite_editor'); + await page.getByPlaceholder('Memory content...').fill('neovim'); + await page.getByPlaceholder('e.g. preferences, context, facts').fill('preferences'); + await page.getByRole('button', { name: 'Save' }).last().click(); + await expect(page.getByText('favorite_editor')).toBeVisible(); + + await page.goto('/_app/config'); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Configuration saved successfully.')).toBeVisible(); + + await page.goto('/_app/cost'); + await expect(page.getByText('Token Statistics')).toBeVisible(); + await expect(page.getByText('anthropic/claude-sonnet-4.6')).toBeVisible(); + + await page.goto('/_app/logs'); + await expect(page.getByText('Scheduler heartbeat ok.', { exact: false })).toBeVisible(); + await page.getByRole('button', { name: 'Pause' }).click(); + await expect(page.getByRole('button', { name: 'Resume' })).toBeVisible(); + + await page.goto('/_app/doctor'); + await page.getByRole('button', { name: 'Run Diagnostics' }).click(); + await expect(page.getByText('Configuration looks healthy.')).toBeVisible(); + await expect(page.getByText('Webhook endpoint is not configured.')).toBeVisible(); +}); diff --git a/web/e2e/locales.spec.ts b/web/e2e/locales.spec.ts new file mode 100644 index 000000000..ab20062b2 --- /dev/null +++ b/web/e2e/locales.spec.ts @@ -0,0 +1,118 @@ +import { expect, test, type Page } from '@playwright/test'; +import { getLanguageOption, getLocaleDirection, LANGUAGE_OPTIONS, LANGUAGE_SWITCH_ORDER, tLocale, type Locale } from '../src/lib/i18n'; + +const routes: Array<{ + path: string; + titleKey: string; + assertion?: (page: Page, locale: Locale) => Promise; +}> = [ + { + path: '/_app/', + titleKey: 'nav.dashboard', + assertion: (page, locale) => expect(page.getByText(tLocale('dashboard.hero_title', locale), { exact: false })).toBeVisible(), + }, + { + path: '/_app/agent', + titleKey: 'nav.agent', + assertion: (page, locale) => expect(page.getByPlaceholder(tLocale('agent.placeholder', locale))).toBeVisible(), + }, + { + path: '/_app/tools', + titleKey: 'nav.tools', + assertion: (page, locale) => expect(page.getByPlaceholder(tLocale('tools.search', locale))).toBeVisible(), + }, + { + path: '/_app/cron', + titleKey: 'nav.cron', + assertion: (page, locale) => expect(page.getByRole('button', { name: tLocale('cron.add', locale) })).toBeVisible(), + }, + { path: '/_app/integrations', titleKey: 'nav.integrations' }, + { + path: '/_app/memory', + titleKey: 'nav.memory', + assertion: (page, locale) => expect(page.getByRole('button', { name: tLocale('memory.add_memory', locale) })).toBeVisible(), + }, + { + path: '/_app/config', + titleKey: 'nav.config', + assertion: (page, locale) => expect(page.getByRole('button', { name: tLocale('config.save', locale) })).toBeVisible(), + }, + { + path: '/_app/cost', + titleKey: 'nav.cost', + assertion: (page, locale) => expect(page.getByText(tLocale('cost.token_statistics', locale), { exact: false })).toBeVisible(), + }, + { + path: '/_app/logs', + titleKey: 'nav.logs', + assertion: (page, locale) => expect(page.getByText(tLocale('logs.title', locale), { exact: false })).toBeVisible(), + }, + { + path: '/_app/doctor', + titleKey: 'nav.doctor', + assertion: (page, locale) => expect(page.getByRole('heading', { name: tLocale('doctor.title', locale) }).first()).toBeVisible(), + }, +]; + +async function chooseLocale(page: Page, locale: Locale) { + if (await page.locator('html').getAttribute('lang') === locale) { + await expect(page.getByTestId('locale-flag')).toHaveText(getLanguageOption(locale).flag); + return; + } + + await page.getByTestId('locale-select').click(); + await expect(page.getByTestId('locale-menu')).toBeVisible(); + await page.getByTestId(`locale-option-${locale}`).click(); + await expect(page.getByTestId('locale-menu')).toBeHidden(); +} + +test.describe('localization', () => { + test('locale selector lists every configured language', async ({ page }) => { + await page.goto('/_app/'); + await page.getByTestId('locale-select').click(); + await expect(page.getByTestId('locale-menu')).toBeVisible(); + + for (const option of LANGUAGE_OPTIONS) { + const optionLocator = page.getByTestId(`locale-option-${option.value}`); + await expect(optionLocator).toContainText(option.flag); + await expect(optionLocator).toContainText(option.label); + } + }); + + test('pairing screen supports every locale', async ({ page }) => { + await page.goto('/_app/'); + + for (const locale of LANGUAGE_SWITCH_ORDER) { + await chooseLocale(page, locale); + await expect(page.getByTestId('locale-flag')).toHaveText(getLanguageOption(locale).flag); + await expect(page.locator('html')).toHaveAttribute('lang', locale); + await expect(page.locator('html')).toHaveAttribute('dir', getLocaleDirection(locale)); + await expect(page.locator('body')).toHaveAttribute('dir', getLocaleDirection(locale)); + await expect(page.getByTestId('pair-button')).toHaveText(tLocale('auth.pair_button', locale)); + await expect(page.getByPlaceholder(tLocale('auth.code_placeholder', locale))).toBeVisible(); + await expect(page.getByText(tLocale('auth.enter_code', locale))).toBeVisible(); + } + }); + + for (const locale of LANGUAGE_SWITCH_ORDER) { + test(`authenticated dashboard shell localizes cleanly for ${locale}`, async ({ page }) => { + await page.addInitScript((selectedLocale) => { + window.localStorage.setItem('zeroclaw_token', 'test-token'); + window.localStorage.setItem('zeroclaw:locale', selectedLocale); + }, locale); + + for (const route of routes) { + await page.goto(route.path); + await chooseLocale(page, locale); + await expect(page.getByTestId('locale-flag')).toHaveText(getLanguageOption(locale).flag); + await expect(page.locator('html')).toHaveAttribute('lang', locale); + await expect(page.locator('html')).toHaveAttribute('dir', getLocaleDirection(locale)); + await expect(page.locator('body')).toHaveAttribute('dir', getLocaleDirection(locale)); + await expect(page.locator('header h1')).toHaveText(tLocale(route.titleKey, locale)); + if (route.assertion) { + await route.assertion(page, locale); + } + } + }); + } +}); diff --git a/web/e2e/mock-server.mjs b/web/e2e/mock-server.mjs new file mode 100644 index 000000000..776681b16 --- /dev/null +++ b/web/e2e/mock-server.mjs @@ -0,0 +1,353 @@ +import http from 'node:http'; +import { WebSocketServer } from 'ws'; + +const PORT = Number(process.env.PW_API_PORT ?? '4174'); +const VALID_TOKEN = 'test-token'; +const VALID_CODE = '123456'; + +let cronJobs = [ + { + id: 'job-alpha', + name: 'nightly sync', + command: 'sync --nightly', + next_run: '2026-03-10T00:00:00.000Z', + last_run: '2026-03-09T00:00:00.000Z', + last_status: 'ok', + enabled: true, + }, +]; + +let memoryEntries = [ + { + id: 'memory-1', + key: 'workspace_mode', + content: 'Dashboard E2E workspace is active.', + category: 'context', + timestamp: '2026-03-09T12:00:00.000Z', + session_id: 'session-1', + score: 0.99, + }, +]; + +const healthSnapshot = { + pid: 4242, + updated_at: '2026-03-09T12:00:00.000Z', + uptime_seconds: 12345, + components: { + gateway: { + status: 'ok', + updated_at: '2026-03-09T12:00:00.000Z', + last_ok: '2026-03-09T12:00:00.000Z', + last_error: null, + restart_count: 0, + }, + scheduler: { + status: 'ok', + updated_at: '2026-03-09T12:00:00.000Z', + last_ok: '2026-03-09T12:00:00.000Z', + last_error: null, + restart_count: 1, + }, + }, +}; + +function sendJson(res, statusCode, body) { + res.writeHead(statusCode, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(body)); +} + +function unauthorized(res) { + sendJson(res, 401, { error: 'Unauthorized' }); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', (chunk) => { + data += chunk; + }); + req.on('end', () => resolve(data)); + req.on('error', reject); + }); +} + +function isAuthorized(req) { + const authHeader = req.headers.authorization; + if (authHeader === `Bearer ${VALID_TOKEN}`) { + return true; + } + + const url = new URL(req.url, `http://${req.headers.host}`); + return url.searchParams.get('token') === VALID_TOKEN; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + + if (req.method === 'GET' && url.pathname === '/health') { + return sendJson(res, 200, { require_pairing: true, paired: false }); + } + + if (req.method === 'POST' && url.pathname === '/pair') { + if (req.headers['x-pairing-code'] !== VALID_CODE) { + return sendJson(res, 400, { error: 'Invalid pairing code' }); + } + return sendJson(res, 200, { token: VALID_TOKEN }); + } + + if (url.pathname.startsWith('/api') && !isAuthorized(req)) { + return unauthorized(res); + } + + if (req.method === 'GET' && url.pathname === '/api/status') { + return sendJson(res, 200, { + provider: 'openrouter', + model: 'anthropic/claude-sonnet-4.6', + temperature: 0.7, + uptime_seconds: 12345, + gateway_port: PORT, + locale: 'en', + memory_backend: 'sqlite', + paired: true, + channels: { + discord: true, + telegram: false, + slack: false, + }, + health: healthSnapshot, + }); + } + + if (req.method === 'GET' && url.pathname === '/api/health') { + return sendJson(res, 200, { health: healthSnapshot }); + } + + if (req.method === 'GET' && url.pathname === '/api/config') { + return sendJson(res, 200, { + format: 'toml', + content: [ + 'default_provider = "openrouter"', + 'default_model = "anthropic/claude-sonnet-4.6"', + '', + '[gateway]', + `port = ${PORT}`, + ].join('\n'), + }); + } + + if (req.method === 'PUT' && url.pathname === '/api/config') { + await readBody(req); + return sendJson(res, 200, { status: 'ok' }); + } + + if (req.method === 'GET' && url.pathname === '/api/tools') { + return sendJson(res, 200, { + tools: [ + { + name: 'shell', + description: 'Execute shell commands in the active workspace.', + parameters: { type: 'object', properties: { command: { type: 'string' } } }, + }, + { + name: 'memory_store', + description: 'Persist a memory entry.', + parameters: { type: 'object', properties: { key: { type: 'string' } } }, + }, + ], + }); + } + + if (req.method === 'GET' && url.pathname === '/api/cli-tools') { + return sendJson(res, 200, { + cli_tools: [ + { name: 'git', path: '/usr/bin/git', version: '2.39.0', category: 'vcs' }, + { name: 'cargo', path: '/usr/bin/cargo', version: '1.86.0', category: 'rust' }, + ], + }); + } + + if (req.method === 'GET' && url.pathname === '/api/cron') { + return sendJson(res, 200, { jobs: cronJobs }); + } + + if (req.method === 'POST' && url.pathname === '/api/cron') { + const raw = await readBody(req); + const body = JSON.parse(raw || '{}'); + const job = { + id: `job-${Date.now()}`, + name: body.name ?? null, + command: body.command, + next_run: '2026-03-11T00:00:00.000Z', + last_run: null, + last_status: null, + enabled: body.enabled ?? true, + }; + cronJobs = [...cronJobs, job]; + return sendJson(res, 200, { status: 'ok', job }); + } + + if (req.method === 'DELETE' && url.pathname.startsWith('/api/cron/')) { + const id = decodeURIComponent(url.pathname.split('/').pop()); + cronJobs = cronJobs.filter((job) => job.id !== id); + res.writeHead(204); + return res.end(); + } + + if (req.method === 'GET' && url.pathname === '/api/integrations') { + return sendJson(res, 200, { + integrations: [ + { + name: 'Discord', + description: 'Send notifications and respond in channels.', + category: 'chat', + status: 'Active', + }, + { + name: 'GitHub', + description: 'Track pull requests and issues.', + category: 'devops', + status: 'Available', + }, + { + name: 'Linear', + description: 'Sync roadmap items and tasks.', + category: 'project', + status: 'ComingSoon', + }, + ], + }); + } + + if (req.method === 'POST' && url.pathname === '/api/doctor') { + return sendJson(res, 200, { + results: [ + { severity: 'ok', category: 'config', message: 'Configuration looks healthy.' }, + { severity: 'warn', category: 'network', message: 'Webhook endpoint is not configured.' }, + ], + }); + } + + if (req.method === 'GET' && url.pathname === '/api/memory') { + const query = url.searchParams.get('query')?.toLowerCase() ?? ''; + const category = url.searchParams.get('category') ?? ''; + const entries = memoryEntries.filter((entry) => { + const matchesQuery = !query + || entry.key.toLowerCase().includes(query) + || entry.content.toLowerCase().includes(query); + const matchesCategory = !category || entry.category === category; + return matchesQuery && matchesCategory; + }); + return sendJson(res, 200, { entries }); + } + + if (req.method === 'POST' && url.pathname === '/api/memory') { + const raw = await readBody(req); + const body = JSON.parse(raw || '{}'); + const entry = { + id: `memory-${Date.now()}`, + key: body.key, + content: body.content, + category: body.category || 'notes', + timestamp: new Date().toISOString(), + session_id: 'session-e2e', + score: 1, + }; + memoryEntries = [entry, ...memoryEntries]; + return sendJson(res, 200, { status: 'ok' }); + } + + if (req.method === 'DELETE' && url.pathname.startsWith('/api/memory/')) { + const key = decodeURIComponent(url.pathname.split('/').pop()); + memoryEntries = memoryEntries.filter((entry) => entry.key !== key); + res.writeHead(204); + return res.end(); + } + + if (req.method === 'GET' && url.pathname === '/api/cost') { + return sendJson(res, 200, { + cost: { + session_cost_usd: 0.0132, + daily_cost_usd: 0.1024, + monthly_cost_usd: 2.3811, + total_tokens: 48231, + request_count: 128, + by_model: { + 'anthropic/claude-sonnet-4.6': { + model: 'anthropic/claude-sonnet-4.6', + cost_usd: 1.8123, + total_tokens: 35123, + request_count: 84, + }, + 'openai/gpt-4o-mini': { + model: 'openai/gpt-4o-mini', + cost_usd: 0.5688, + total_tokens: 13108, + request_count: 44, + }, + }, + }, + }); + } + + if (req.method === 'GET' && url.pathname === '/api/events') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + const push = (payload) => { + res.write(`event: ${payload.type}\n`); + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }; + + push({ type: 'status', timestamp: new Date().toISOString(), message: 'Event stream connected.' }); + const interval = setInterval(() => { + push({ type: 'health', timestamp: new Date().toISOString(), message: 'Scheduler heartbeat ok.' }); + }, 750); + + req.on('close', () => { + clearInterval(interval); + res.end(); + }); + return; + } + + sendJson(res, 404, { error: 'Not found' }); +}); + +const wsServer = new WebSocketServer({ noServer: true }); + +wsServer.on('connection', (socket) => { + socket.send(JSON.stringify({ type: 'message', content: 'Connected to mock ZeroClaw runtime.' })); + + socket.on('message', (raw) => { + const message = JSON.parse(String(raw)); + if (message.type !== 'message') { + return; + } + + socket.send(JSON.stringify({ type: 'chunk', content: 'Echo: ' })); + socket.send(JSON.stringify({ type: 'done', content: `Echo: ${message.content}` })); + }); +}); + +server.on('upgrade', (req, socket, head) => { + const url = new URL(req.url, `http://${req.headers.host}`); + if (url.pathname !== '/ws/chat' || !isAuthorized(req)) { + socket.write('HTTP/1.1 401 Unauthorized\\r\\n\\r\\n'); + socket.destroy(); + return; + } + + wsServer.handleUpgrade(req, socket, head, (client) => { + wsServer.emit('connection', client, req); + }); +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`Mock backend listening on http://127.0.0.1:${PORT}`); +}); diff --git a/web/package-lock.json b/web/package-lock.json index eae0be85f..c55e51e15 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "zeroclaw-web", "version": "0.1.0", + "license": "(MIT OR Apache-2.0)", "dependencies": { "lucide-react": "^0.468.0", "react": "^19.0.0", @@ -14,6 +15,7 @@ "react-router-dom": "^7.1.1" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.0.0", "@types/node": "^25.3.0", "@types/react": "^19.0.7", @@ -21,7 +23,9 @@ "@vitejs/plugin-react": "^4.3.4", "tailwindcss": "^4.0.0", "typescript": "~5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^4.0.18", + "ws": "^8.19.0" } }, "node_modules/@babel/code-frame": { @@ -798,6 +802,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1155,6 +1175,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz", @@ -1472,6 +1499,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1530,6 +1575,127 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -1598,6 +1764,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1674,6 +1850,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1726,6 +1909,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2142,6 +2345,24 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2162,6 +2383,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2327,6 +2595,13 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2337,6 +2612,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", @@ -2358,6 +2647,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -2375,6 +2681,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -2502,6 +2818,123 @@ } } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index 5ed0f68bc..7376e0d1f 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,10 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", + "test:unit": "vitest run --config vitest.config.ts" }, "dependencies": { "lucide-react": "^0.468.0", @@ -16,6 +19,7 @@ "react-router-dom": "^7.1.1" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.0.0", "@types/node": "^25.3.0", "@types/react": "^19.0.7", @@ -23,6 +27,8 @@ "@vitejs/plugin-react": "^4.3.4", "tailwindcss": "^4.0.0", "typescript": "~5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^4.0.18", + "ws": "^8.19.0" } } diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 000000000..dd46ea939 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const FRONTEND_PORT = Number(process.env.PW_WEB_PORT ?? '4173'); +const BACKEND_PORT = Number(process.env.PW_API_PORT ?? '4174'); +const rootDir = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + testDir: './e2e', + timeout: 60_000, + expect: { + timeout: 10_000, + }, + use: { + baseURL: `http://127.0.0.1:${FRONTEND_PORT}`, + headless: true, + trace: 'on-first-retry', + }, + webServer: [ + { + command: `node e2e/mock-server.mjs`, + url: `http://127.0.0.1:${BACKEND_PORT}/health`, + reuseExistingServer: true, + cwd: rootDir, + }, + { + command: `VITE_API_TARGET=http://127.0.0.1:${BACKEND_PORT} VITE_WS_TARGET=ws://127.0.0.1:${BACKEND_PORT} npm run dev -- --host 127.0.0.1 --port ${FRONTEND_PORT}`, + url: `http://127.0.0.1:${FRONTEND_PORT}/_app/`, + reuseExistingServer: true, + cwd: rootDir, + }, + ], +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 85e71d82b..2cd4e7326 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,27 +12,48 @@ import Cost from './pages/Cost'; import Logs from './pages/Logs'; import Doctor from './pages/Doctor'; import { AuthProvider, useAuth } from './hooks/useAuth'; -import { setLocale, type Locale } from './lib/i18n'; +import { + applyLocaleToDocument, + coerceLocale, + getLocaleDirection, + setLocale, + tLocale, + type Locale, +} from './lib/i18n'; +import LanguageSelector from './components/controls/LanguageSelector'; + +const LOCALE_STORAGE_KEY = 'zeroclaw:locale'; // Locale context interface LocaleContextType { - locale: string; - setAppLocale: (locale: string) => void; + locale: Locale; + setAppLocale: (locale: Locale) => void; } export const LocaleContext = createContext({ - locale: 'tr', - setAppLocale: () => {}, + locale: 'en', + setAppLocale: (_locale: Locale) => {}, }); export const useLocaleContext = () => useContext(LocaleContext); // Pairing dialog component -function PairingDialog({ onPair }: { onPair: (code: string) => Promise }) { +function PairingDialog({ + locale, + setAppLocale, + onPair, +}: { + locale: Locale; + setAppLocale: (locale: Locale) => void; + onPair: (code: string) => Promise; +}) { const [code, setCode] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const translate = (key: string) => tLocale(key, locale); + const localeDirection = getLocaleDirection(locale); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); @@ -40,38 +61,49 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise }) try { await onPair(code); } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Pairing failed'); + setError(err instanceof Error ? err.message : translate('auth.pairing_failed')); } finally { setLoading(false); } }; return ( -
-
+
+
+
+ +
-

ZeroClaw

-

Enter the pairing code from your terminal

+

ZEROCLAW

+

{translate('auth.enter_code')}

setCode(e.target.value)} - placeholder="6-digit code" - className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-center text-2xl tracking-widest focus:outline-none focus:border-blue-500 mb-4" + placeholder={translate('auth.code_placeholder')} + className="w-full rounded-xl border border-[#29509c] bg-[#071228]/90 px-4 py-3 text-center text-2xl tracking-[0.35em] text-white focus:border-[#4f83ff] focus:outline-none mb-4" maxLength={6} autoFocus /> {error && ( -

{error}

+

{error}

)}
@@ -81,11 +113,38 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise }) function AppContent() { const { isAuthenticated, loading, pair, logout } = useAuth(); - const [locale, setLocaleState] = useState('tr'); + const [locale, setLocaleState] = useState(() => { + const initialLocale = (() => { + if (typeof window === 'undefined') { + return 'en'; + } - const setAppLocale = (newLocale: string) => { + const saved = window.localStorage.getItem(LOCALE_STORAGE_KEY); + if (saved) { + return coerceLocale(saved); + } + + return coerceLocale(window.navigator.language); + })(); + + setLocale(initialLocale); + if (typeof document !== 'undefined') { + applyLocaleToDocument(initialLocale, document); + } + return initialLocale; + }); + + useEffect(() => { + setLocale(locale); + if (typeof window !== 'undefined') { + window.localStorage.setItem(LOCALE_STORAGE_KEY, locale); + applyLocaleToDocument(locale, document); + } + }, [locale]); + + const setAppLocale = (newLocale: Locale) => { + setLocale(newLocale); setLocaleState(newLocale); - setLocale(newLocale as Locale); }; // Listen for 401 events to force logout @@ -99,19 +158,22 @@ function AppContent() { if (loading) { return ( -
-

Connecting...

+
+
+
+

{tLocale('common.connecting', locale)}

+
); } if (!isAuthenticated) { - return ; + return ; } return ( - + }> } /> } /> diff --git a/web/src/components/controls/LanguageSelector.tsx b/web/src/components/controls/LanguageSelector.tsx new file mode 100644 index 000000000..2c60ab3b5 --- /dev/null +++ b/web/src/components/controls/LanguageSelector.tsx @@ -0,0 +1,129 @@ +import { Check, ChevronDown } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { + getLanguageOption, + getLanguageOptionLabel, + getLocaleDirection, + LANGUAGE_OPTIONS, + type Locale, +} from '@/lib/i18n'; + +interface LanguageSelectorProps { + locale: Locale; + onChange: (locale: Locale) => void; + ariaLabel: string; + title?: string; + align?: 'left' | 'right'; + buttonClassName?: string; + menuClassName?: string; +} + +export default function LanguageSelector({ + locale, + onChange, + ariaLabel, + title, + align = 'right', + buttonClassName = '', + menuClassName = '', +}: LanguageSelectorProps) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const activeLanguage = getLanguageOption(locale); + const localeDirection = getLocaleDirection(locale); + + useEffect(() => { + const handlePointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + } + }; + + window.addEventListener('mousedown', handlePointerDown); + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('mousedown', handlePointerDown); + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + const alignmentClass = align === 'left' ? 'left-0' : 'right-0'; + + return ( +
+ + + {open ? ( +
+
+ {LANGUAGE_OPTIONS.map((option) => { + const selected = option.value === locale; + return ( + + ); + })} +
+
+ ) : null} +
+ ); +} diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 7e26ba6b5..05ce96dd2 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -1,6 +1,7 @@ import { useLocation } from 'react-router-dom'; -import { LogOut } from 'lucide-react'; -import { t } from '@/lib/i18n'; +import { LogOut, Menu } from 'lucide-react'; +import { t, tLocale } from '@/lib/i18n'; +import LanguageSelector from '@/components/controls/LanguageSelector'; import { useLocaleContext } from '@/App'; import { useAuth } from '@/hooks/useAuth'; @@ -17,42 +18,59 @@ const routeTitles: Record = { '/doctor': 'nav.doctor', }; -export default function Header() { +interface HeaderProps { + onToggleSidebar: () => void; +} + +export default function Header({ onToggleSidebar }: HeaderProps) { const location = useLocation(); const { logout } = useAuth(); const { locale, setAppLocale } = useLocaleContext(); const titleKey = routeTitles[location.pathname] ?? 'nav.dashboard'; - const pageTitle = t(titleKey); - - const toggleLanguage = () => { - setAppLocale(locale === 'en' ? 'tr' : 'en'); - }; + const pageTitle = tLocale(titleKey, locale); return ( -
- {/* Page title */} -

{pageTitle}

+
+
- {/* Right-side controls */} -
- {/* Language switcher */} +
- {/* Logout */} +
+

+ {pageTitle} +

+

+ {tLocale('header.dashboard_tagline', locale)} +

+
+
+ +
+ +
diff --git a/web/src/components/layout/Layout.tsx b/web/src/components/layout/Layout.tsx index b31f127b4..94f010342 100644 --- a/web/src/components/layout/Layout.tsx +++ b/web/src/components/layout/Layout.tsx @@ -1,19 +1,47 @@ import { Outlet } from 'react-router-dom'; +import { useState } from 'react'; import Sidebar from '@/components/layout/Sidebar'; import Header from '@/components/layout/Header'; +const SIDEBAR_COLLAPSED_KEY = 'zeroclaw:sidebar-collapsed'; + export default function Layout() { + const [sidebarOpen, setSidebarOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { + if (typeof window === 'undefined') { + return false; + } + return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1'; + }); + + const toggleSidebarCollapsed = () => { + setSidebarCollapsed((prev) => { + const next = !prev; + if (typeof window !== 'undefined') { + window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? '1' : '0'); + } + return next; + }); + }; + return ( -
- {/* Fixed sidebar */} - +
+ setSidebarOpen(false)} + onToggleCollapse={toggleSidebarCollapsed} + /> - {/* Main area offset by sidebar width (240px / w-60) */} -
-
+
+
setSidebarOpen((open) => !open)} /> - {/* Page content */} -
+
diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index e378229d4..57740c80a 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -1,5 +1,7 @@ +import { useEffect, useState } from 'react'; import { NavLink } from 'react-router-dom'; import { + ChevronsLeftRightEllipsis, LayoutDashboard, MessageSquare, Wrench, @@ -10,9 +12,12 @@ import { DollarSign, Activity, Stethoscope, + X, } from 'lucide-react'; import { t } from '@/lib/i18n'; +const COLLAPSE_BUTTON_DELAY_MS = 1000; + const navItems = [ { to: '/', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, { to: '/agent', icon: MessageSquare, labelKey: 'nav.agent' }, @@ -26,40 +31,125 @@ const navItems = [ { to: '/doctor', icon: Stethoscope, labelKey: 'nav.doctor' }, ]; -export default function Sidebar() { - return ( - +export default function Sidebar({ + isOpen, + isCollapsed, + onClose, + onToggleCollapse, +}: SidebarProps) { + const [showCollapseButton, setShowCollapseButton] = useState(false); + + useEffect(() => { + const id = setTimeout(() => setShowCollapseButton(true), COLLAPSE_BUTTON_DELAY_MS); + return () => clearTimeout(id); + }, []); + + return ( + <> + + )} + +
+
+ + + +
+

{t('sidebar.gateway_dashboard')}

+

+ {isCollapsed ? 'UI' : t('sidebar.runtime_mode')} +

+
+ + ); } diff --git a/web/src/index.css b/web/src/index.css index 66e881a91..5208cf0ec 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,89 +1,598 @@ @import "tailwindcss"; -/* - * ZeroClaw Dark Theme - * Dark-mode by default with gray cards and blue/green accents. - */ - @theme { - --color-bg-primary: #0a0a0f; - --color-bg-secondary: #12121a; - --color-bg-card: #1a1a2e; - --color-bg-card-hover: #22223a; - --color-bg-input: #14141f; - - --color-border-default: #2a2a3e; - --color-border-subtle: #1e1e30; - - --color-accent-blue: #3b82f6; - --color-accent-blue-hover: #2563eb; - --color-accent-green: #10b981; - --color-accent-green-hover: #059669; - - --color-text-primary: #e2e8f0; - --color-text-secondary: #94a3b8; - --color-text-muted: #64748b; - - --color-status-success: #10b981; - --color-status-warning: #f59e0b; - --color-status-error: #ef4444; - --color-status-info: #3b82f6; + --color-electric-50: #eaf3ff; + --color-electric-100: #d8e8ff; + --color-electric-300: #87b8ff; + --color-electric-500: #2f8fff; + --color-electric-700: #0f57dd; + --color-electric-900: #031126; } -/* Base styles */ html { color-scheme: dark; } body { - background-color: var(--color-bg-primary); - color: var(--color-text-primary); + margin: 0; + min-height: 100dvh; + color: #edf4ff; + background: #020813; font-family: - "Inter", - ui-sans-serif, - system-ui, - -apple-system, + "Sora", + "Manrope", + "Avenir Next", + "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; + overflow-x: hidden; } #root { - min-height: 100vh; + min-height: 100dvh; +} + +.app-shell { + position: relative; + isolation: isolate; + background: + radial-gradient(circle at 8% 5%, rgba(47, 143, 255, 0.22), transparent 35%), + radial-gradient(circle at 92% 14%, rgba(0, 209, 255, 0.16), transparent 32%), + linear-gradient(175deg, #020816 0%, #03091b 46%, #040e24 100%); +} + +.app-shell::before, +.app-shell::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: -1; +} + +.app-shell::before { + background-image: + linear-gradient(rgba(76, 118, 194, 0.1) 1px, transparent 1px), + linear-gradient(90deg, rgba(76, 118, 194, 0.1) 1px, transparent 1px); + background-size: 34px 34px; + mask-image: radial-gradient(circle at 50% 36%, black 22%, transparent 80%); + opacity: 0.35; +} + +.app-shell::after { + background: + radial-gradient(circle at 16% 86%, rgba(42, 128, 255, 0.34), transparent 43%), + radial-gradient(circle at 84% 22%, rgba(0, 212, 255, 0.2), transparent 38%), + radial-gradient(circle at 52% 122%, rgba(40, 118, 255, 0.3), transparent 56%); + filter: blur(4px); + animation: appGlowDrift 28s ease-in-out infinite; +} + +.glass-header { + position: relative; + backdrop-filter: blur(16px); + background: linear-gradient(160deg, rgba(6, 19, 45, 0.85), rgba(5, 14, 33, 0.9)); + box-shadow: + 0 18px 32px -28px rgba(68, 145, 255, 0.95), + inset 0 1px 0 rgba(140, 183, 255, 0.14); +} + +.glass-header::after { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + background: linear-gradient(105deg, transparent 10%, rgba(79, 155, 255, 0.24), transparent 70%); + transform: translateX(-70%); + animation: topGlowSweep 7s ease-in-out infinite; + pointer-events: none; +} + +.hero-panel { + position: relative; + overflow: hidden; + border-radius: 1.25rem; + border: 1px solid #21438c; + padding: 1.15rem 1.2rem; + background: + radial-gradient(circle at 0% 0%, rgba(56, 143, 255, 0.24), transparent 40%), + linear-gradient(146deg, rgba(8, 26, 64, 0.95), rgba(4, 13, 34, 0.92)); + box-shadow: + inset 0 1px 0 rgba(130, 174, 255, 0.16), + 0 22px 50px -38px rgba(64, 145, 255, 0.94); +} + +.hero-panel::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(118deg, transparent, rgba(128, 184, 255, 0.12), transparent 70%); + transform: translateX(-62%); + animation: heroSweep 5.8s ease-in-out infinite; + pointer-events: none; +} + +.hero-panel::before { + content: ""; + position: absolute; + width: 19rem; + height: 19rem; + right: -6rem; + top: -10rem; + border-radius: 999px; + background: radial-gradient(circle at center, rgba(63, 167, 255, 0.42), transparent 70%); + filter: blur(12px); + animation: heroGlowPulse 4.8s ease-in-out infinite; + pointer-events: none; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border-radius: 999px; + border: 1px solid #2d58ac; + background: rgba(6, 29, 78, 0.68); + color: #c2d9ff; + padding: 0.35rem 0.65rem; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.12em; + box-shadow: 0 0 22px -16px rgba(83, 153, 255, 0.95); +} + +.electric-brand-mark { + display: flex; + align-items: center; + justify-content: center; + background: + radial-gradient(circle at 22% 18%, rgba(94, 200, 255, 0.35), rgba(23, 119, 255, 0.2) 52%, rgba(10, 72, 181, 0.28) 100%); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.24), + 0 10px 25px -12px rgba(41, 130, 255, 0.95); +} + +.electric-card { + position: relative; + overflow: hidden; + border-radius: 1rem; + border: 1px solid #1a3670; + background: + linear-gradient(165deg, rgba(8, 24, 60, 0.95), rgba(4, 14, 34, 0.96)); + box-shadow: + inset 0 1px 0 rgba(129, 174, 255, 0.12), + 0 25px 45px -36px rgba(47, 140, 255, 0.95), + 0 0 0 1px rgba(63, 141, 255, 0.2), + 0 0 26px -17px rgba(76, 176, 255, 0.82); +} + +.electric-card::after { + content: ""; + position: absolute; + left: -20%; + right: -20%; + bottom: -65%; + height: 72%; + border-radius: 50%; + background: radial-gradient(circle, rgba(59, 148, 255, 0.25), transparent 72%); + filter: blur(16px); + opacity: 0.45; + pointer-events: none; + animation: cardGlowPulse 5.2s ease-in-out infinite; +} + +.electric-icon { + display: flex; + align-items: center; + justify-content: center; + color: #9bc3ff; + background: + radial-gradient(circle at 35% 22%, rgba(123, 198, 255, 0.38), rgba(29, 92, 214, 0.32) 66%, rgba(12, 44, 102, 0.48) 100%); + border: 1px solid rgba(86, 143, 255, 0.45); +} + +.metric-head { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border-radius: 999px; + border: 1px solid #244991; + background: rgba(6, 22, 54, 0.74); + color: #91b8fb; + font-size: 0.66rem; + letter-spacing: 0.11em; + text-transform: uppercase; + padding: 0.3rem 0.55rem; +} + +.metric-value { + color: #ffffff; + font-size: clamp(1.15rem, 1.8vw, 1.45rem); + font-weight: 620; + letter-spacing: 0.02em; +} + +.metric-sub { + color: #89aee8; + font-size: 0.78rem; +} + +.metric-pill { + border-radius: 0.85rem; + border: 1px solid #1d3c77; + background: rgba(5, 17, 44, 0.86); + padding: 0.6rem 0.72rem; + box-shadow: + 0 0 0 1px rgba(62, 137, 255, 0.16), + 0 16px 30px -24px rgba(47, 140, 255, 0.86), + 0 0 18px -15px rgba(73, 176, 255, 0.78); +} + +.metric-pill span { + display: block; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #85a9e1; +} + +.metric-pill strong { + display: block; + margin-top: 0.2rem; + font-size: 0.93rem; + color: #f5f9ff; +} + +.electric-progress { + background: + linear-gradient(90deg, #1f76ff 0%, #2f97ff 60%, #48cdff 100%); + box-shadow: 0 0 18px -7px rgba(62, 166, 255, 0.95); +} + +.pairing-shell { + position: relative; + isolation: isolate; + min-height: 100dvh; + background: + radial-gradient(circle at 20% 5%, rgba(64, 141, 255, 0.24), transparent 35%), + radial-gradient(circle at 75% 92%, rgba(0, 193, 255, 0.13), transparent 35%), + linear-gradient(155deg, #020816 0%, #030c20 58%, #030915 100%); + overflow: hidden; +} + +.pairing-shell::after { + content: ""; + position: absolute; + inset: -20%; + background: + radial-gradient(circle at 10% 20%, rgba(84, 173, 255, 0.25), transparent 60%), + radial-gradient(circle at 85% 80%, rgba(0, 204, 255, 0.22), transparent 60%); + filter: blur(12px); + opacity: 0.7; + animation: pairingSpotlightSweep 18s ease-in-out infinite; + pointer-events: none; + z-index: -1; +} + +.pairing-card { + position: relative; + overflow: hidden; + border: 1px solid #2956a8; + background: + linear-gradient(155deg, rgba(9, 27, 68, 0.9), rgba(4, 15, 35, 0.94)); + box-shadow: + inset 0 1px 0 rgba(146, 190, 255, 0.16), + 0 30px 60px -44px rgba(47, 141, 255, 0.98), + 0 0 0 1px rgba(67, 150, 255, 0.2), + 0 0 28px -18px rgba(76, 184, 255, 0.82); +} + +.pairing-card::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + background: linear-gradient(135deg, transparent 10%, rgba(102, 186, 255, 0.5), transparent 80%); + mix-blend-mode: screen; + opacity: 0.0; + transform: translateX(-65%); + animation: pairingCardSweep 7.5s ease-in-out infinite; + pointer-events: none; +} + +.pairing-brand { + background-image: linear-gradient(120deg, #5bc0ff 0%, #f9e775 28%, #5bc0ff 56%, #f9e775 100%); + background-size: 260% 260%; + background-clip: text; + -webkit-background-clip: text; + color: transparent; + text-shadow: + 0 0 14px rgba(96, 189, 255, 0.8), + 0 0 32px rgba(46, 138, 255, 0.9), + 0 0 42px rgba(252, 238, 147, 0.85); + letter-spacing: 0.18em; + animation: pairingElectricCharge 5.4s ease-in-out infinite; +} + +:is(div, section, article)[class*="bg-gray-900"][class*="rounded-xl"][class*="border"], +:is(div, section, article)[class*="bg-gray-900"][class*="rounded-lg"][class*="border"], +:is(div, section, article)[class*="bg-gray-950"][class*="rounded-lg"][class*="border"] { + box-shadow: + 0 0 0 1px rgba(67, 144, 255, 0.14), + 0 22px 40px -32px rgba(45, 134, 255, 0.86), + 0 0 22px -16px rgba(73, 180, 255, 0.75); +} + +.electric-button { + border: 1px solid #4a89ff; + background: linear-gradient(126deg, #125bdf 0%, #1f88ff 55%, #17b4ff 100%); + box-shadow: 0 18px 30px -20px rgba(47, 141, 255, 0.9); + transition: transform 180ms ease, filter 180ms ease, box-shadow 180ms ease; +} + +.electric-button:hover { + transform: translateY(-1px); + filter: brightness(1.05); + box-shadow: 0 20px 34px -19px rgba(56, 154, 255, 0.95); +} + +.electric-loader { + border: 3px solid rgba(89, 146, 255, 0.22); + border-top-color: #51abff; + box-shadow: 0 0 20px -12px rgba(66, 157, 255, 1); + animation: spin 1s linear infinite; +} + +.motion-rise { + animation: riseIn 580ms ease both; +} + +.motion-delay-1 { + animation-delay: 70ms; +} + +.motion-delay-2 { + animation-delay: 130ms; +} + +.motion-delay-3 { + animation-delay: 190ms; +} + +.motion-delay-4 { + animation-delay: 250ms; +} + +* { + scrollbar-width: thin; + scrollbar-color: #244787 #081126; } -/* Scrollbar styling */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { - background: var(--color-bg-secondary); + background: #081126; } ::-webkit-scrollbar-thumb { - background: var(--color-border-default); - border-radius: 4px; + background: #244787; + border-radius: 999px; } ::-webkit-scrollbar-thumb:hover { - background: var(--color-text-muted); + background: #3160b6; } -/* Card utility */ -.card { - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-default); - border-radius: 0.75rem; -} - -.card:hover { - background-color: var(--color-bg-card-hover); -} - -/* Focus ring utility */ *:focus-visible { - outline: 2px solid var(--color-accent-blue); + outline: 2px solid #4ea4ff; outline-offset: 2px; } + +@keyframes riseIn { + from { + opacity: 0; + transform: translateY(14px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes heroSweep { + 0%, + 100% { + transform: translateX(-68%); + opacity: 0; + } + 30% { + opacity: 0.65; + } + 60% { + transform: translateX(58%); + opacity: 0; + } +} + +@keyframes heroGlowPulse { + 0%, + 100% { + opacity: 0.38; + transform: scale(0.94); + } + 50% { + opacity: 0.72; + transform: scale(1.08); + } +} + +@keyframes cardGlowPulse { + 0%, + 100% { + opacity: 0.28; + transform: translateY(0) scale(0.96); + } + 50% { + opacity: 0.55; + transform: translateY(-2%) scale(1.04); + } +} + +@keyframes pairingElectricCharge { + 0%, + 100% { + background-position: 0% 50%; + text-shadow: + 0 0 14px rgba(86, 177, 255, 0.5), + 0 0 32px rgba(36, 124, 255, 0.7), + 0 0 38px rgba(252, 238, 147, 0.6); + transform: translateY(0) scale(1); + } + 35% { + background-position: 80% 50%; + text-shadow: + 0 0 26px rgba(138, 218, 255, 1), + 0 0 52px rgba(56, 176, 255, 1), + 0 0 60px rgba(252, 238, 147, 1); + transform: translateY(-1px) scale(1.06); + } + 60% { + background-position: 50% 50%; + text-shadow: + 0 0 18px rgba(86, 177, 255, 0.7), + 0 0 36px rgba(36, 124, 255, 0.8), + 0 0 44px rgba(252, 238, 147, 0.7); + transform: translateY(0) scale(1.02); + } +} + +@keyframes pairingSpotlightSweep { + 0% { + transform: translate3d(-12%, 8%, 0) scale(1); + opacity: 0.45; + } + 30% { + transform: translate3d(10%, -4%, 0) scale(1.06); + opacity: 0.7; + } + 55% { + transform: translate3d(16%, 10%, 0) scale(1.1); + opacity: 0.6; + } + 80% { + transform: translate3d(-8%, -6%, 0) scale(1.04); + opacity: 0.5; + } + 100% { + transform: translate3d(-12%, 8%, 0) scale(1); + opacity: 0.45; + } +} + +@keyframes pairingCardSweep { + 0%, + 100% { + transform: translateX(-70%); + opacity: 0; + } + 25% { + opacity: 0.55; + } + 50% { + transform: translateX(55%); + opacity: 0; + } +} + +@keyframes topGlowSweep { + 0%, + 100% { + transform: translateX(-78%); + opacity: 0; + } + 30% { + opacity: 0.55; + } + 58% { + transform: translateX(58%); + opacity: 0; + } +} + +@keyframes appGlowDrift { + 0% { + opacity: 0.3; + transform: translate3d(-3%, 1.8%, 0) scale(1); + } + 25% { + opacity: 0.5; + transform: translate3d(2.6%, -1.2%, 0) scale(1.04); + } + 50% { + opacity: 0.56; + transform: translate3d(4.4%, -3.4%, 0) scale(1.09); + } + 75% { + opacity: 0.44; + transform: translate3d(-1.8%, -2.1%, 0) scale(1.05); + } + 100% { + opacity: 0.34; + transform: translate3d(-3.6%, 2.6%, 0) scale(1.01); + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (max-width: 768px) { + .hero-panel { + padding: 0.95rem 0.95rem; + } + + .status-pill { + padding: 0.28rem 0.52rem; + font-size: 0.61rem; + letter-spacing: 0.1em; + } + + .metric-value { + font-size: 1.08rem; + } + + .metric-sub { + font-size: 0.74rem; + } + + .electric-card { + border-radius: 0.9rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .hero-panel::after, + .hero-panel::before, + .glass-header::after, + .electric-card::after, + .app-shell::after, + .pairing-shell::after, + .pairing-card::before, + .motion-rise, + .electric-loader { + animation: none !important; + } + + .electric-button { + transition: none !important; + } +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 181462b9b..af81fd109 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -72,6 +72,21 @@ function unwrapField(value: T | Record, key: string): T { return value as T; } +function normalizeMemoryCategory(value: unknown): string { + if (typeof value === 'string') { + return value; + } + + if (value && typeof value === 'object') { + const custom = (value as { custom?: unknown }).custom; + if (typeof custom === 'string' && custom.trim().length > 0) { + return custom; + } + } + + return 'unknown'; +} + // --------------------------------------------------------------------------- // Pairing // --------------------------------------------------------------------------- @@ -208,7 +223,11 @@ export function getMemory( if (category) params.set('category', category); const qs = params.toString(); return apiFetch(`/api/memory${qs ? `?${qs}` : ''}`).then( - (data) => unwrapField(data, 'entries'), + (data) => + unwrapField(data, 'entries').map((entry) => ({ + ...entry, + category: normalizeMemoryCategory((entry as { category: unknown }).category), + })), ); } diff --git a/web/src/lib/i18n.test.ts b/web/src/lib/i18n.test.ts new file mode 100644 index 000000000..d760fd3ea --- /dev/null +++ b/web/src/lib/i18n.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { + applyLocaleToDocument, + coerceLocale, + getLanguageOption, + getLanguageOptionLabel, + getLocaleDirection, + LANGUAGE_OPTIONS, + LANGUAGE_SWITCH_ORDER, +} from './i18n'; + +describe('language metadata', () => { + it('keeps language options aligned with switch order', () => { + expect(LANGUAGE_OPTIONS.map((option) => option.value)).toEqual(LANGUAGE_SWITCH_ORDER); + expect(new Set(LANGUAGE_OPTIONS.map((option) => option.value)).size).toBe(LANGUAGE_OPTIONS.length); + }); + + it('provides a flag-backed label for every locale', () => { + for (const option of LANGUAGE_OPTIONS) { + expect(getLanguageOption(option.value)).toEqual(option); + expect(getLanguageOptionLabel(option)).toBe(option.label); + expect(option.flag.length).toBeGreaterThan(0); + } + }); +}); + +describe('coerceLocale', () => { + it('normalizes browser locale variants to supported locales', () => { + expect(coerceLocale('ar-SA')).toBe('ar'); + expect(coerceLocale('he-IL')).toBe('he'); + expect(coerceLocale('iw-IL')).toBe('he'); + expect(coerceLocale('pt-BR')).toBe('pt'); + expect(coerceLocale('no-NO')).toBe('nb'); + expect(coerceLocale('zh-Hans')).toBe('zh-CN'); + expect(coerceLocale(undefined)).toBe('en'); + }); +}); + +describe('locale direction', () => { + it('returns rtl only for rtl languages', () => { + expect(getLocaleDirection('ar')).toBe('rtl'); + expect(getLocaleDirection('he')).toBe('rtl'); + expect(getLocaleDirection('ur')).toBe('rtl'); + expect(getLocaleDirection('en')).toBe('ltr'); + expect(getLocaleDirection('ja')).toBe('ltr'); + }); + + it('applies lang and dir to a document-like target', () => { + const target = { + documentElement: { lang: '', dir: '' }, + body: { dir: '' }, + }; + + applyLocaleToDocument('ar', target); + expect(target.documentElement.lang).toBe('ar'); + expect(target.documentElement.dir).toBe('rtl'); + expect(target.body.dir).toBe('rtl'); + + applyLocaleToDocument('fr', target); + expect(target.documentElement.lang).toBe('fr'); + expect(target.documentElement.dir).toBe('ltr'); + expect(target.body.dir).toBe('ltr'); + }); +}); diff --git a/web/src/lib/i18n.ts b/web/src/lib/i18n.ts index eac6ad02b..3a3d2fe04 100644 --- a/web/src/lib/i18n.ts +++ b/web/src/lib/i18n.ts @@ -1,383 +1,1623 @@ import { useState, useEffect } from 'react'; import { getStatus } from './api'; -// --------------------------------------------------------------------------- -// Translation dictionaries -// --------------------------------------------------------------------------- +export type Locale = + | 'en' + | 'zh-CN' + | 'ja' + | 'ko' + | 'vi' + | 'tl' + | 'es' + | 'pt' + | 'it' + | 'de' + | 'fr' + | 'ar' + | 'hi' + | 'ru' + | 'bn' + | 'he' + | 'pl' + | 'cs' + | 'nl' + | 'tr' + | 'uk' + | 'id' + | 'th' + | 'ur' + | 'ro' + | 'sv' + | 'el' + | 'hu' + | 'fi' + | 'da' + | 'nb'; -export type Locale = 'en' | 'tr'; +export const LANGUAGE_SWITCH_ORDER: ReadonlyArray = [ + 'en', + 'zh-CN', + 'ja', + 'ko', + 'vi', + 'tl', + 'es', + 'pt', + 'it', + 'de', + 'fr', + 'ar', + 'hi', + 'ru', + 'bn', + 'he', + 'pl', + 'cs', + 'nl', + 'tr', + 'uk', + 'id', + 'th', + 'ur', + 'ro', + 'sv', + 'el', + 'hu', + 'fi', + 'da', + 'nb', +]; -const translations: Record> = { - en: { - // Navigation - 'nav.dashboard': 'Dashboard', - 'nav.agent': 'Agent', - 'nav.tools': 'Tools', - 'nav.cron': 'Scheduled Jobs', - 'nav.integrations': 'Integrations', - 'nav.memory': 'Memory', - 'nav.config': 'Configuration', - 'nav.cost': 'Cost Tracker', - 'nav.logs': 'Logs', - 'nav.doctor': 'Doctor', +export type LocaleDirection = 'ltr' | 'rtl'; - // Dashboard - 'dashboard.title': 'Dashboard', - 'dashboard.provider': 'Provider', - 'dashboard.model': 'Model', - 'dashboard.uptime': 'Uptime', - 'dashboard.temperature': 'Temperature', - 'dashboard.gateway_port': 'Gateway Port', - 'dashboard.locale': 'Locale', - 'dashboard.memory_backend': 'Memory Backend', - 'dashboard.paired': 'Paired', - 'dashboard.channels': 'Channels', - 'dashboard.health': 'Health', - 'dashboard.status': 'Status', - 'dashboard.overview': 'Overview', - 'dashboard.system_info': 'System Information', - 'dashboard.quick_actions': 'Quick Actions', +export interface LanguageOption { + value: Locale; + label: string; + flag: string; + direction: LocaleDirection; +} - // Agent / Chat - 'agent.title': 'Agent Chat', - 'agent.send': 'Send', - 'agent.placeholder': 'Type a message...', - 'agent.connecting': 'Connecting...', - 'agent.connected': 'Connected', - 'agent.disconnected': 'Disconnected', - 'agent.reconnecting': 'Reconnecting...', - 'agent.thinking': 'Thinking...', - 'agent.tool_call': 'Tool Call', - 'agent.tool_result': 'Tool Result', +export const LANGUAGE_OPTIONS: ReadonlyArray = [ + { value: 'en', label: 'English', flag: '🇺🇸', direction: 'ltr' }, + { value: 'zh-CN', label: '简体中文', flag: '🇨🇳', direction: 'ltr' }, + { value: 'ja', label: '日本語', flag: '🇯🇵', direction: 'ltr' }, + { value: 'ko', label: '한국어', flag: '🇰🇷', direction: 'ltr' }, + { value: 'vi', label: 'Tiếng Việt', flag: '🇻🇳', direction: 'ltr' }, + { value: 'tl', label: 'Tagalog', flag: '🇵🇭', direction: 'ltr' }, + { value: 'es', label: 'Español', flag: '🇪🇸', direction: 'ltr' }, + { value: 'pt', label: 'Português', flag: '🇵🇹', direction: 'ltr' }, + { value: 'it', label: 'Italiano', flag: '🇮🇹', direction: 'ltr' }, + { value: 'de', label: 'Deutsch', flag: '🇩🇪', direction: 'ltr' }, + { value: 'fr', label: 'Français', flag: '🇫🇷', direction: 'ltr' }, + { value: 'ar', label: 'العربية', flag: '🇸🇦', direction: 'rtl' }, + { value: 'hi', label: 'हिन्दी', flag: '🇮🇳', direction: 'ltr' }, + { value: 'ru', label: 'Русский', flag: '🇷🇺', direction: 'ltr' }, + { value: 'bn', label: 'বাংলা', flag: '🇧🇩', direction: 'ltr' }, + { value: 'he', label: 'עברית', flag: '🇮🇱', direction: 'rtl' }, + { value: 'pl', label: 'Polski', flag: '🇵🇱', direction: 'ltr' }, + { value: 'cs', label: 'Čeština', flag: '🇨🇿', direction: 'ltr' }, + { value: 'nl', label: 'Nederlands', flag: '🇳🇱', direction: 'ltr' }, + { value: 'tr', label: 'Türkçe', flag: '🇹🇷', direction: 'ltr' }, + { value: 'uk', label: 'Українська', flag: '🇺🇦', direction: 'ltr' }, + { value: 'id', label: 'Bahasa Indonesia', flag: '🇮🇩', direction: 'ltr' }, + { value: 'th', label: 'ไทย', flag: '🇹🇭', direction: 'ltr' }, + { value: 'ur', label: 'اردو', flag: '🇵🇰', direction: 'rtl' }, + { value: 'ro', label: 'Română', flag: '🇷🇴', direction: 'ltr' }, + { value: 'sv', label: 'Svenska', flag: '🇸🇪', direction: 'ltr' }, + { value: 'el', label: 'Ελληνικά', flag: '🇬🇷', direction: 'ltr' }, + { value: 'hu', label: 'Magyar', flag: '🇭🇺', direction: 'ltr' }, + { value: 'fi', label: 'Suomi', flag: '🇫🇮', direction: 'ltr' }, + { value: 'da', label: 'Dansk', flag: '🇩🇰', direction: 'ltr' }, + { value: 'nb', label: 'Norsk Bokmål', flag: '🇳🇴', direction: 'ltr' }, +]; - // Tools - 'tools.title': 'Available Tools', - 'tools.name': 'Name', - 'tools.description': 'Description', - 'tools.parameters': 'Parameters', - 'tools.search': 'Search tools...', - 'tools.empty': 'No tools available.', - 'tools.count': 'Total tools', +const RTL_LOCALES = new Set(['ar', 'he', 'ur']); - // Cron - 'cron.title': 'Scheduled Jobs', - 'cron.add': 'Add Job', - 'cron.delete': 'Delete', - 'cron.enable': 'Enable', - 'cron.disable': 'Disable', - 'cron.name': 'Name', - 'cron.command': 'Command', - 'cron.schedule': 'Schedule', - 'cron.next_run': 'Next Run', - 'cron.last_run': 'Last Run', - 'cron.last_status': 'Last Status', - 'cron.enabled': 'Enabled', - 'cron.empty': 'No scheduled jobs.', - 'cron.confirm_delete': 'Are you sure you want to delete this job?', +export function getLocaleDirection(locale: Locale): LocaleDirection { + return RTL_LOCALES.has(locale) ? 'rtl' : 'ltr'; +} - // Integrations - 'integrations.title': 'Integrations', - 'integrations.available': 'Available', - 'integrations.active': 'Active', - 'integrations.coming_soon': 'Coming Soon', - 'integrations.category': 'Category', - 'integrations.status': 'Status', - 'integrations.search': 'Search integrations...', - 'integrations.empty': 'No integrations found.', - 'integrations.activate': 'Activate', - 'integrations.deactivate': 'Deactivate', +export function getLanguageOption(locale: Locale): LanguageOption { + const matched = LANGUAGE_OPTIONS.find((option) => option.value === locale); + if (matched) { + return matched; + } - // Memory - 'memory.title': 'Memory Store', - 'memory.search': 'Search memory...', - 'memory.add': 'Store Memory', - 'memory.delete': 'Delete', - 'memory.key': 'Key', - 'memory.content': 'Content', - 'memory.category': 'Category', - 'memory.timestamp': 'Timestamp', - 'memory.session': 'Session', - 'memory.score': 'Score', - 'memory.empty': 'No memory entries found.', - 'memory.confirm_delete': 'Are you sure you want to delete this memory entry?', - 'memory.all_categories': 'All Categories', + const fallback = LANGUAGE_OPTIONS.find((option) => option.value === 'en'); + if (!fallback) { + throw new Error('English locale metadata is missing.'); + } - // Config - 'config.title': 'Configuration', - 'config.save': 'Save', - 'config.reset': 'Reset', - 'config.saved': 'Configuration saved successfully.', - 'config.error': 'Failed to save configuration.', - 'config.loading': 'Loading configuration...', - 'config.editor_placeholder': 'TOML configuration...', + return fallback; +} - // Cost - 'cost.title': 'Cost Tracker', - 'cost.session': 'Session Cost', - 'cost.daily': 'Daily Cost', - 'cost.monthly': 'Monthly Cost', - 'cost.total_tokens': 'Total Tokens', - 'cost.request_count': 'Requests', - 'cost.by_model': 'Cost by Model', - 'cost.model': 'Model', - 'cost.tokens': 'Tokens', - 'cost.requests': 'Requests', - 'cost.usd': 'Cost (USD)', +export function getLanguageOptionLabel(option: LanguageOption): string { + return option.label; +} - // Logs - 'logs.title': 'Live Logs', - 'logs.clear': 'Clear', - 'logs.pause': 'Pause', - 'logs.resume': 'Resume', - 'logs.filter': 'Filter logs...', - 'logs.empty': 'No log entries.', - 'logs.connected': 'Connected to event stream.', - 'logs.disconnected': 'Disconnected from event stream.', +export interface LocaleDocumentTarget { + documentElement?: { lang?: string; dir?: string }; + body?: { dir?: string } | null; +} - // Doctor - 'doctor.title': 'System Diagnostics', - 'doctor.run': 'Run Diagnostics', - 'doctor.running': 'Running diagnostics...', - 'doctor.ok': 'OK', - 'doctor.warn': 'Warning', - 'doctor.error': 'Error', - 'doctor.severity': 'Severity', - 'doctor.category': 'Category', - 'doctor.message': 'Message', - 'doctor.empty': 'No diagnostics have been run yet.', - 'doctor.summary': 'Diagnostic Summary', +export function applyLocaleToDocument(locale: Locale, target: LocaleDocumentTarget): void { + const direction = getLocaleDirection(locale); - // Auth / Pairing - 'auth.pair': 'Pair Device', - 'auth.pairing_code': 'Pairing Code', - 'auth.pair_button': 'Pair', - 'auth.logout': 'Logout', - 'auth.pairing_success': 'Pairing successful!', - 'auth.pairing_failed': 'Pairing failed. Please try again.', - 'auth.enter_code': 'Enter your pairing code to connect to the agent.', + if (target.documentElement) { + target.documentElement.lang = locale; + target.documentElement.dir = direction; + } - // Common - 'common.loading': 'Loading...', - 'common.error': 'An error occurred.', - 'common.retry': 'Retry', - 'common.cancel': 'Cancel', - 'common.confirm': 'Confirm', - 'common.save': 'Save', - 'common.delete': 'Delete', - 'common.edit': 'Edit', - 'common.close': 'Close', - 'common.yes': 'Yes', - 'common.no': 'No', - 'common.search': 'Search...', - 'common.no_data': 'No data available.', - 'common.refresh': 'Refresh', - 'common.back': 'Back', - 'common.actions': 'Actions', - 'common.name': 'Name', - 'common.description': 'Description', - 'common.status': 'Status', - 'common.created': 'Created', - 'common.updated': 'Updated', + if (target.body) { + target.body.dir = direction; + } +} - // Health - 'health.title': 'System Health', - 'health.component': 'Component', - 'health.status': 'Status', - 'health.last_ok': 'Last OK', - 'health.last_error': 'Last Error', - 'health.restart_count': 'Restarts', - 'health.pid': 'Process ID', - 'health.uptime': 'Uptime', - 'health.updated_at': 'Last Updated', - }, - - tr: { - // Navigation - 'nav.dashboard': 'Kontrol Paneli', - 'nav.agent': 'Ajan', - 'nav.tools': 'Araclar', - 'nav.cron': 'Zamanlanmis Gorevler', - 'nav.integrations': 'Entegrasyonlar', - 'nav.memory': 'Hafiza', - 'nav.config': 'Yapilandirma', - 'nav.cost': 'Maliyet Takibi', - 'nav.logs': 'Kayitlar', - 'nav.doctor': 'Doktor', - - // Dashboard - 'dashboard.title': 'Kontrol Paneli', - 'dashboard.provider': 'Saglayici', - 'dashboard.model': 'Model', - 'dashboard.uptime': 'Calisma Suresi', - 'dashboard.temperature': 'Sicaklik', - 'dashboard.gateway_port': 'Gecit Portu', - 'dashboard.locale': 'Yerel Ayar', - 'dashboard.memory_backend': 'Hafiza Motoru', - 'dashboard.paired': 'Eslestirilmis', - 'dashboard.channels': 'Kanallar', - 'dashboard.health': 'Saglik', - 'dashboard.status': 'Durum', - 'dashboard.overview': 'Genel Bakis', - 'dashboard.system_info': 'Sistem Bilgisi', - 'dashboard.quick_actions': 'Hizli Islemler', - - // Agent / Chat - 'agent.title': 'Ajan Sohbet', - 'agent.send': 'Gonder', - 'agent.placeholder': 'Bir mesaj yazin...', - 'agent.connecting': 'Baglaniyor...', - 'agent.connected': 'Bagli', - 'agent.disconnected': 'Baglanti Kesildi', - 'agent.reconnecting': 'Yeniden Baglaniyor...', - 'agent.thinking': 'Dusunuyor...', - 'agent.tool_call': 'Arac Cagrisi', - 'agent.tool_result': 'Arac Sonucu', - - // Tools - 'tools.title': 'Mevcut Araclar', - 'tools.name': 'Ad', - 'tools.description': 'Aciklama', - 'tools.parameters': 'Parametreler', - 'tools.search': 'Arac ara...', - 'tools.empty': 'Mevcut arac yok.', - 'tools.count': 'Toplam arac', - - // Cron - 'cron.title': 'Zamanlanmis Gorevler', - 'cron.add': 'Gorev Ekle', - 'cron.delete': 'Sil', - 'cron.enable': 'Etkinlestir', - 'cron.disable': 'Devre Disi Birak', - 'cron.name': 'Ad', - 'cron.command': 'Komut', - 'cron.schedule': 'Zamanlama', - 'cron.next_run': 'Sonraki Calistirma', - 'cron.last_run': 'Son Calistirma', - 'cron.last_status': 'Son Durum', - 'cron.enabled': 'Etkin', - 'cron.empty': 'Zamanlanmis gorev yok.', - 'cron.confirm_delete': 'Bu gorevi silmek istediginizden emin misiniz?', - - // Integrations - 'integrations.title': 'Entegrasyonlar', - 'integrations.available': 'Mevcut', - 'integrations.active': 'Aktif', - 'integrations.coming_soon': 'Yakinda', - 'integrations.category': 'Kategori', - 'integrations.status': 'Durum', - 'integrations.search': 'Entegrasyon ara...', - 'integrations.empty': 'Entegrasyon bulunamadi.', - 'integrations.activate': 'Etkinlestir', - 'integrations.deactivate': 'Devre Disi Birak', - - // Memory - 'memory.title': 'Hafiza Deposu', - 'memory.search': 'Hafizada ara...', - 'memory.add': 'Hafiza Kaydet', - 'memory.delete': 'Sil', - 'memory.key': 'Anahtar', - 'memory.content': 'Icerik', - 'memory.category': 'Kategori', - 'memory.timestamp': 'Zaman Damgasi', - 'memory.session': 'Oturum', - 'memory.score': 'Skor', - 'memory.empty': 'Hafiza kaydi bulunamadi.', - 'memory.confirm_delete': 'Bu hafiza kaydini silmek istediginizden emin misiniz?', - 'memory.all_categories': 'Tum Kategoriler', - - // Config - 'config.title': 'Yapilandirma', - 'config.save': 'Kaydet', - 'config.reset': 'Sifirla', - 'config.saved': 'Yapilandirma basariyla kaydedildi.', - 'config.error': 'Yapilandirma kaydedilemedi.', - 'config.loading': 'Yapilandirma yukleniyor...', - 'config.editor_placeholder': 'TOML yapilandirmasi...', - - // Cost - 'cost.title': 'Maliyet Takibi', - 'cost.session': 'Oturum Maliyeti', - 'cost.daily': 'Gunluk Maliyet', - 'cost.monthly': 'Aylik Maliyet', - 'cost.total_tokens': 'Toplam Token', - 'cost.request_count': 'Istekler', - 'cost.by_model': 'Modele Gore Maliyet', - 'cost.model': 'Model', - 'cost.tokens': 'Token', - 'cost.requests': 'Istekler', - 'cost.usd': 'Maliyet (USD)', - - // Logs - 'logs.title': 'Canli Kayitlar', - 'logs.clear': 'Temizle', - 'logs.pause': 'Duraklat', - 'logs.resume': 'Devam Et', - 'logs.filter': 'Kayitlari filtrele...', - 'logs.empty': 'Kayit girisi yok.', - 'logs.connected': 'Olay akisina baglandi.', - 'logs.disconnected': 'Olay akisi baglantisi kesildi.', - - // Doctor - 'doctor.title': 'Sistem Teshisleri', - 'doctor.run': 'Teshis Calistir', - 'doctor.running': 'Teshisler calistiriliyor...', - 'doctor.ok': 'Tamam', - 'doctor.warn': 'Uyari', - 'doctor.error': 'Hata', - 'doctor.severity': 'Ciddiyet', - 'doctor.category': 'Kategori', - 'doctor.message': 'Mesaj', - 'doctor.empty': 'Henuz teshis calistirilmadi.', - 'doctor.summary': 'Teshis Ozeti', - - // Auth / Pairing - 'auth.pair': 'Cihaz Esle', - 'auth.pairing_code': 'Eslestirme Kodu', - 'auth.pair_button': 'Esle', - 'auth.logout': 'Cikis Yap', - 'auth.pairing_success': 'Eslestirme basarili!', - 'auth.pairing_failed': 'Eslestirme basarisiz. Lutfen tekrar deneyin.', - 'auth.enter_code': 'Ajana baglanmak icin eslestirme kodunuzu girin.', - - // Common - 'common.loading': 'Yukleniyor...', - 'common.error': 'Bir hata olustu.', - 'common.retry': 'Tekrar Dene', - 'common.cancel': 'Iptal', - 'common.confirm': 'Onayla', - 'common.save': 'Kaydet', - 'common.delete': 'Sil', - 'common.edit': 'Duzenle', - 'common.close': 'Kapat', - 'common.yes': 'Evet', - 'common.no': 'Hayir', - 'common.search': 'Ara...', - 'common.no_data': 'Veri mevcut degil.', - 'common.refresh': 'Yenile', - 'common.back': 'Geri', - 'common.actions': 'Islemler', - 'common.name': 'Ad', - 'common.description': 'Aciklama', - 'common.status': 'Durum', - 'common.created': 'Olusturulma', - 'common.updated': 'Guncellenme', - - // Health - 'health.title': 'Sistem Sagligi', - 'health.component': 'Bilesen', - 'health.status': 'Durum', - 'health.last_ok': 'Son Basarili', - 'health.last_error': 'Son Hata', - 'health.restart_count': 'Yeniden Baslatmalar', - 'health.pid': 'Islem Kimligi', - 'health.uptime': 'Calisma Suresi', - 'health.updated_at': 'Son Guncelleme', - }, +export const LANGUAGE_BUTTON_LABELS: Record = { + en: 'EN', + 'zh-CN': '中文', + ja: '日本語', + ko: 'KO', + vi: 'VI', + tl: 'TL', + es: 'ES', + pt: 'PT', + it: 'IT', + de: 'DE', + fr: 'FR', + ar: 'AR', + hi: 'HI', + ru: 'RU', + bn: 'BN', + he: 'HE', + pl: 'PL', + cs: 'CS', + nl: 'NL', + tr: 'TR', + uk: 'UK', + id: 'ID', + th: 'TH', + ur: 'UR', + ro: 'RO', + sv: 'SV', + el: 'EL', + hu: 'HU', + fi: 'FI', + da: 'DA', + nb: 'NB', }; -// --------------------------------------------------------------------------- -// Current locale state -// --------------------------------------------------------------------------- +const en = { + 'nav.dashboard': 'Dashboard', + 'nav.agent': 'Agent', + 'nav.tools': 'Tools', + 'nav.cron': 'Scheduled Jobs', + 'nav.integrations': 'Integrations', + 'nav.memory': 'Memory', + 'nav.config': 'Configuration', + 'nav.cost': 'Cost Tracker', + 'nav.logs': 'Logs', + 'nav.doctor': 'Doctor', + + 'dashboard.title': 'Dashboard', + 'dashboard.provider': 'Provider', + 'dashboard.model': 'Model', + 'dashboard.uptime': 'Uptime', + 'dashboard.temperature': 'Temperature', + 'dashboard.gateway_port': 'Gateway Port', + 'dashboard.locale': 'Locale', + 'dashboard.memory_backend': 'Memory Backend', + 'dashboard.paired': 'Paired', + 'dashboard.channels': 'Channels', + 'dashboard.health': 'Health', + 'dashboard.status': 'Status', + 'dashboard.overview': 'Overview', + 'dashboard.system_info': 'System Information', + 'dashboard.quick_actions': 'Quick Actions', + 'dashboard.load_failed': 'Dashboard load failed', + 'dashboard.load_unknown_error': 'Unknown dashboard load error', + 'dashboard.hero_eyebrow': 'ZeroClaw Command Deck', + 'dashboard.hero_title': 'Electric Runtime Dashboard', + 'dashboard.hero_subtitle': 'Real-time telemetry, cost pulse, and operations status in a single collapsible surface.', + 'dashboard.live_gateway': 'Live Gateway', + 'dashboard.unpaired': 'Unpaired', + 'dashboard.provider_model': 'Provider / Model', + 'dashboard.since_last_restart': 'Since last restart', + 'dashboard.pairing_active': 'Pairing active', + 'dashboard.no_paired_devices': 'No paired devices', + 'dashboard.cost_pulse': 'Cost Pulse', + 'dashboard.cost_subtitle': 'Session, daily, and monthly runtime spend', + 'dashboard.session': 'Session', + 'dashboard.daily': 'Daily', + 'dashboard.monthly': 'Monthly', + 'dashboard.channel_activity': 'Channel Activity', + 'dashboard.channel_subtitle': 'Live integrations and route connectivity', + 'dashboard.no_channels': 'No channels configured.', + 'dashboard.active': 'Active', + 'dashboard.inactive': 'Inactive', + 'dashboard.component_health': 'Component Health', + 'dashboard.component_subtitle': 'Runtime heartbeat and restart awareness', + 'dashboard.no_component_health': 'No component health is currently available.', + 'dashboard.restarts': 'Restarts', + 'dashboard.unknown_provider': 'Unknown', + + 'agent.title': 'Agent Chat', + 'agent.send': 'Send', + 'agent.placeholder': 'Type a message...', + 'agent.connecting': 'Connecting...', + 'agent.connected': 'Connected', + 'agent.disconnected': 'Disconnected', + 'agent.reconnecting': 'Reconnecting...', + 'agent.thinking': 'Thinking...', + 'agent.tool_call': 'Tool Call', + 'agent.tool_result': 'Tool Result', + 'agent.connection_error': 'Connection error. Attempting to reconnect...', + 'agent.failed_send': 'Failed to send message. Please try again.', + 'agent.empty_title': 'ZeroClaw Agent', + 'agent.empty_subtitle': 'Send a message to start the conversation', + 'agent.unknown_error': 'Unknown error', + + 'tools.title': 'Available Tools', + 'tools.name': 'Name', + 'tools.description': 'Description', + 'tools.parameters': 'Parameters', + 'tools.search': 'Search tools...', + 'tools.empty': 'No tools available.', + 'tools.count': 'Total tools', + 'tools.agent_tools': 'Agent Tools', + 'tools.cli_tools': 'CLI Tools', + 'tools.no_search_results': 'No tools match your search.', + 'tools.parameter_schema': 'Parameter Schema', + 'tools.path': 'Path', + 'tools.version': 'Version', + 'tools.load_failed': 'Failed to load tools', + + 'cron.title': 'Scheduled Jobs', + 'cron.add': 'Add Job', + 'cron.delete': 'Delete', + 'cron.enable': 'Enable', + 'cron.disable': 'Disable', + 'cron.name': 'Name', + 'cron.command': 'Command', + 'cron.schedule': 'Schedule', + 'cron.next_run': 'Next Run', + 'cron.last_run': 'Last Run', + 'cron.last_status': 'Last Status', + 'cron.enabled': 'Enabled', + 'cron.empty': 'No scheduled jobs.', + 'cron.confirm_delete': 'Are you sure you want to delete this job?', + 'cron.scheduled_tasks': 'Scheduled Tasks', + 'cron.add_cron_job': 'Add Cron Job', + 'cron.name_optional': 'Name (optional)', + 'cron.schedule_required_command_required': 'Schedule and command are required.', + 'cron.adding': 'Adding...', + 'cron.no_tasks_configured': 'No scheduled tasks configured.', + 'cron.load_failed': 'Failed to load cron jobs', + 'cron.failed_add': 'Failed to add job', + 'cron.failed_delete': 'Failed to delete job', + 'cron.delete_prompt': 'Delete?', + 'cron.id': 'ID', + 'cron.disabled': 'Disabled', + + 'integrations.title': 'Integrations', + 'integrations.available': 'Available', + 'integrations.active': 'Active', + 'integrations.coming_soon': 'Coming Soon', + 'integrations.category': 'Category', + 'integrations.status': 'Status', + 'integrations.search': 'Search integrations...', + 'integrations.empty': 'No integrations found.', + 'integrations.activate': 'Activate', + 'integrations.deactivate': 'Deactivate', + 'integrations.load_failed': 'Failed to load integrations', + 'integrations.all': 'all', + + 'memory.title': 'Memory Store', + 'memory.search': 'Search memory...', + 'memory.add': 'Store Memory', + 'memory.delete': 'Delete', + 'memory.key': 'Key', + 'memory.content': 'Content', + 'memory.category': 'Category', + 'memory.timestamp': 'Timestamp', + 'memory.session': 'Session', + 'memory.score': 'Score', + 'memory.empty': 'No memory entries found.', + 'memory.confirm_delete': 'Are you sure you want to delete this memory entry?', + 'memory.all_categories': 'All Categories', + 'memory.add_memory': 'Add Memory', + 'memory.search_entries': 'Search memory entries...', + 'memory.load_failed': 'Failed to load memory', + 'memory.key_content_required': 'Key and content are required.', + 'memory.failed_store': 'Failed to store memory', + 'memory.failed_delete': 'Failed to delete memory', + 'memory.category_optional': 'Category (optional)', + 'memory.key_placeholder': 'e.g. user_preferences', + 'memory.content_placeholder': 'Memory content...', + 'memory.category_placeholder': 'e.g. preferences, context, facts', + 'memory.search_button': 'Search', + 'memory.saving': 'Saving...', + 'memory.delete_prompt': 'Delete?', + + 'config.title': 'Configuration', + 'config.save': 'Save', + 'config.reset': 'Reset', + 'config.saved': 'Configuration saved successfully.', + 'config.error': 'Failed to save configuration.', + 'config.loading': 'Loading configuration...', + 'config.editor_placeholder': 'TOML configuration...', + 'config.saving': 'Saving...', + 'config.masked_title': 'Sensitive fields are masked', + 'config.masked_description': 'API keys, tokens, and passwords are hidden for security. To update a masked field, replace the entire masked value with your new value.', + 'config.toml_configuration': 'TOML Configuration', + 'config.lines': 'lines', + + 'cost.title': 'Cost Tracker', + 'cost.session': 'Session Cost', + 'cost.daily': 'Daily Cost', + 'cost.monthly': 'Monthly Cost', + 'cost.total_tokens': 'Total Tokens', + 'cost.request_count': 'Requests', + 'cost.by_model': 'Cost by Model', + 'cost.model': 'Model', + 'cost.tokens': 'Tokens', + 'cost.requests': 'Requests', + 'cost.usd': 'Cost (USD)', + 'cost.total_requests': 'Total Requests', + 'cost.token_statistics': 'Token Statistics', + 'cost.avg_tokens_per_request': 'Avg Tokens / Request', + 'cost.cost_per_1k_tokens': 'Cost per 1K Tokens', + 'cost.model_breakdown': 'Model Breakdown', + 'cost.no_model_data': 'No model data available.', + 'cost.share': 'Share', + 'cost.load_failed': 'Failed to load cost data', + + 'logs.title': 'Live Logs', + 'logs.clear': 'Clear', + 'logs.pause': 'Pause', + 'logs.resume': 'Resume', + 'logs.filter': 'Filter logs...', + 'logs.empty': 'No log entries.', + 'logs.connected': 'Connected to event stream.', + 'logs.disconnected': 'Disconnected from event stream.', + 'logs.events': 'events', + 'logs.jump_to_bottom': 'Jump to bottom', + 'logs.filter_label': 'Filter:', + 'logs.paused_stream': 'Log streaming is paused.', + 'logs.waiting_for_events': 'Waiting for events...', + + 'doctor.title': 'System Diagnostics', + 'doctor.run': 'Run Diagnostics', + 'doctor.running': 'Running diagnostics...', + 'doctor.ok': 'OK', + 'doctor.warn': 'Warning', + 'doctor.error': 'Error', + 'doctor.severity': 'Severity', + 'doctor.category': 'Category', + 'doctor.message': 'Message', + 'doctor.empty': 'No diagnostics have been run yet.', + 'doctor.summary': 'Diagnostic Summary', + 'doctor.running_short': 'Running...', + 'doctor.running_hint': 'This may take a few seconds.', + 'doctor.issues_found': 'Issues Found', + 'doctor.warnings': 'Warnings', + 'doctor.all_clear': 'All Clear', + 'doctor.instructions': 'Click "Run Diagnostics" to check your ZeroClaw installation.', + + 'auth.pair': 'Pair Device', + 'auth.pairing_code': 'Pairing Code', + 'auth.pair_button': 'Pair', + 'auth.logout': 'Logout', + 'auth.pairing_success': 'Pairing successful!', + 'auth.pairing_failed': 'Pairing failed. Please try again.', + 'auth.enter_code': 'Enter the one-time pairing code from your terminal', + 'auth.code_placeholder': '6-digit code', + 'auth.pairing_progress': 'Pairing...', + + 'common.loading': 'Loading...', + 'common.error': 'An error occurred.', + 'common.retry': 'Retry', + 'common.cancel': 'Cancel', + 'common.confirm': 'Confirm', + 'common.save': 'Save', + 'common.delete': 'Delete', + 'common.edit': 'Edit', + 'common.close': 'Close', + 'common.yes': 'Yes', + 'common.no': 'No', + 'common.search': 'Search...', + 'common.no_data': 'No data available.', + 'common.refresh': 'Refresh', + 'common.back': 'Back', + 'common.actions': 'Actions', + 'common.name': 'Name', + 'common.description': 'Description', + 'common.status': 'Status', + 'common.created': 'Created', + 'common.updated': 'Updated', + 'common.languages': 'Languages', + 'common.select_language': 'Select language', + 'common.connecting': 'Connecting...', + + 'health.title': 'System Health', + 'health.component': 'Component', + 'health.status': 'Status', + 'health.last_ok': 'Last OK', + 'health.last_error': 'Last Error', + 'health.restart_count': 'Restart Count', + 'health.pid': 'Process ID', + 'health.uptime': 'Uptime', + 'health.updated_at': 'Updated At', + + 'header.dashboard_tagline': 'ZeroClaw dashboard', + 'sidebar.gateway_dashboard': 'Gateway + Dashboard', + 'sidebar.runtime_mode': 'Runtime Mode', + 'navigation.open': 'Open navigation', + 'navigation.close': 'Close navigation', + 'navigation.expand': 'Expand navigation', + 'navigation.collapse': 'Collapse navigation', +} satisfies Record; + +const tr = { + ...en, + 'nav.dashboard': 'Kontrol Paneli', + 'nav.agent': 'Ajan', + 'nav.tools': 'Araçlar', + 'nav.cron': 'Zamanlanmış Görevler', + 'nav.integrations': 'Entegrasyonlar', + 'nav.memory': 'Hafıza', + 'nav.config': 'Yapılandırma', + 'nav.cost': 'Maliyet Takibi', + 'nav.logs': 'Kayıtlar', + 'nav.doctor': 'Doktor', + 'agent.title': 'Ajan Sohbeti', + 'agent.send': 'Gönder', + 'agent.placeholder': 'Bir mesaj yazın...', + 'agent.connecting': 'Bağlanıyor...', + 'agent.connected': 'Bağlı', + 'agent.disconnected': 'Bağlantı Kesildi', + 'agent.reconnecting': 'Yeniden bağlanıyor...', + 'agent.thinking': 'Düşünüyor...', + 'agent.tool_call': 'Araç Çağrısı', + 'agent.tool_result': 'Araç Sonucu', + 'agent.connection_error': 'Bağlantı hatası. Yeniden bağlanmaya çalışılıyor...', + 'agent.failed_send': 'Mesaj gönderilemedi. Lütfen tekrar deneyin.', + 'agent.empty_title': 'ZeroClaw Ajanı', + 'agent.empty_subtitle': 'Konuşmayı başlatmak için bir mesaj gönderin', + 'dashboard.title': 'Kontrol Paneli', + 'dashboard.provider': 'Sağlayıcı', + 'dashboard.model': 'Model', + 'dashboard.uptime': 'Çalışma Süresi', + 'dashboard.temperature': 'Sıcaklık', + 'dashboard.gateway_port': 'Ağ Geçidi Portu', + 'dashboard.locale': 'Yerel Ayar', + 'dashboard.memory_backend': 'Hafıza Arka Ucu', + 'dashboard.hero_eyebrow': 'ZeroClaw Komuta Güvertesi', + 'dashboard.hero_title': 'Elektrik Çalışma Zamanı Paneli', + 'dashboard.hero_subtitle': 'Gerçek zamanlı telemetri, maliyet akışı ve operasyon durumunu tek bir daraltılabilir yüzeyde görün.', + 'dashboard.live_gateway': 'Canlı Ağ Geçidi', + 'dashboard.unpaired': 'Eşleşmemiş', + 'dashboard.provider_model': 'Sağlayıcı / Model', + 'dashboard.since_last_restart': 'Son yeniden başlatmadan beri', + 'dashboard.pairing_active': 'Eşleştirme etkin', + 'dashboard.no_paired_devices': 'Eşleşmiş cihaz yok', + 'dashboard.cost_pulse': 'Maliyet Nabzı', + 'dashboard.cost_subtitle': 'Oturum, günlük ve aylık çalışma zamanı harcaması', + 'dashboard.session': 'Oturum', + 'dashboard.daily': 'Günlük', + 'dashboard.monthly': 'Aylık', + 'dashboard.channel_activity': 'Kanal Etkinliği', + 'dashboard.channel_subtitle': 'Canlı entegrasyonlar ve rota bağlantısı', + 'dashboard.no_channels': 'Hiç kanal yapılandırılmamış.', + 'dashboard.active': 'Aktif', + 'dashboard.inactive': 'Pasif', + 'dashboard.component_health': 'Bileşen Sağlığı', + 'dashboard.component_subtitle': 'Çalışma zamanı nabzı ve yeniden başlatma farkındalığı', + 'dashboard.no_component_health': 'Şu anda bileşen sağlığı bilgisi yok.', + 'dashboard.restarts': 'Yeniden Başlatmalar', + 'tools.title': 'Mevcut Araçlar', + 'tools.search': 'Araç ara...', + 'tools.agent_tools': 'Ajan Araçları', + 'tools.cli_tools': 'CLI Araçları', + 'tools.no_search_results': 'Aramanızla eşleşen araç yok.', + 'tools.parameter_schema': 'Parametre Şeması', + 'tools.path': 'Yol', + 'tools.version': 'Sürüm', + 'cron.title': 'Zamanlanmış Görevler', + 'cron.add': 'Görev Ekle', + 'cron.scheduled_tasks': 'Zamanlanmış Görevler', + 'cron.add_cron_job': 'Cron Görevi Ekle', + 'cron.name_optional': 'Ad (isteğe bağlı)', + 'cron.schedule_required_command_required': 'Zamanlama ve komut gereklidir.', + 'cron.adding': 'Ekleniyor...', + 'cron.no_tasks_configured': 'Zamanlanmış görev yapılandırılmamış.', + 'cron.load_failed': 'Cron görevleri yüklenemedi', + 'cron.failed_add': 'Görev eklenemedi', + 'cron.failed_delete': 'Görev silinemedi', + 'cron.delete_prompt': 'Silinsin mi?', + 'cron.disabled': 'Devre Dışı', + 'integrations.title': 'Entegrasyonlar', + 'integrations.available': 'Mevcut', + 'integrations.active': 'Aktif', + 'integrations.coming_soon': 'Yakında', + 'integrations.empty': 'Entegrasyon bulunamadı.', + 'integrations.load_failed': 'Entegrasyonlar yüklenemedi', + 'memory.title': 'Hafıza Deposu', + 'memory.add_memory': 'Hafıza Ekle', + 'memory.search_entries': 'Hafıza girdilerinde ara...', + 'memory.all_categories': 'Tüm Kategoriler', + 'memory.search_button': 'Ara', + 'memory.load_failed': 'Hafıza yüklenemedi', + 'memory.key_content_required': 'Anahtar ve içerik gereklidir.', + 'memory.failed_store': 'Hafıza kaydedilemedi', + 'memory.failed_delete': 'Hafıza silinemedi', + 'memory.category_optional': 'Kategori (isteğe bağlı)', + 'memory.key_placeholder': 'örn. kullanici_tercihleri', + 'memory.content_placeholder': 'Hafıza içeriği...', + 'memory.category_placeholder': 'örn. tercihler, bağlam, gerçekler', + 'memory.saving': 'Kaydediliyor...', + 'memory.delete_prompt': 'Silinsin mi?', + 'config.title': 'Yapılandırma', + 'config.save': 'Kaydet', + 'config.saved': 'Yapılandırma başarıyla kaydedildi.', + 'config.saving': 'Kaydediliyor...', + 'config.masked_title': 'Hassas alanlar maskelendi', + 'config.masked_description': 'Güvenlik için API anahtarları, belirteçler ve parolalar gizlenir. Maskelenmiş bir alanı güncellemek için tüm maskeli değeri yeni değerinizle değiştirin.', + 'config.toml_configuration': 'TOML Yapılandırması', + 'config.lines': 'satır', + 'cost.title': 'Maliyet Takibi', + 'cost.session': 'Oturum Maliyeti', + 'cost.daily': 'Günlük Maliyet', + 'cost.monthly': 'Aylık Maliyet', + 'cost.total_requests': 'Toplam İstek', + 'cost.token_statistics': 'Belirteç İstatistikleri', + 'cost.avg_tokens_per_request': 'İstek Başına Ort. Belirteç', + 'cost.cost_per_1k_tokens': '1K Belirteç Başına Maliyet', + 'cost.model_breakdown': 'Model Dağılımı', + 'cost.no_model_data': 'Model verisi yok.', + 'cost.share': 'Pay', + 'cost.load_failed': 'Maliyet verisi yüklenemedi', + 'logs.title': 'Canlı Kayıtlar', + 'logs.pause': 'Duraklat', + 'logs.resume': 'Sürdür', + 'logs.events': 'olay', + 'logs.jump_to_bottom': 'Alta git', + 'logs.filter_label': 'Filtre:', + 'logs.paused_stream': 'Kayıt akışı duraklatıldı.', + 'logs.waiting_for_events': 'Olaylar bekleniyor...', + 'doctor.title': 'Sistem Teşhisleri', + 'doctor.run': 'Teşhisleri Çalıştır', + 'doctor.running': 'Teşhisler çalıştırılıyor...', + 'doctor.running_short': 'Çalışıyor...', + 'doctor.running_hint': 'Bu birkaç saniye sürebilir.', + 'doctor.warn': 'Uyarı', + 'doctor.issues_found': 'Sorunlar Bulundu', + 'doctor.warnings': 'Uyarılar', + 'doctor.all_clear': 'Temiz', + 'doctor.instructions': 'ZeroClaw kurulumunuzu kontrol etmek için "Teşhisleri Çalıştır" düğmesine tıklayın.', + 'auth.pair': 'Cihazı Eşle', + 'auth.pair_button': 'Eşle', + 'auth.logout': 'Çıkış Yap', + 'auth.enter_code': 'Terminalinizdeki tek kullanımlık eşleştirme kodunu girin', + 'auth.code_placeholder': '6 haneli kod', + 'auth.pairing_progress': 'Eşleştiriliyor...', + 'common.languages': 'Diller', + 'common.select_language': 'Dil seçin', + 'common.connecting': 'Bağlanıyor...', + 'header.dashboard_tagline': 'ZeroClaw paneli', + 'sidebar.gateway_dashboard': 'Ağ Geçidi + Panel', + 'sidebar.runtime_mode': 'Çalışma Modu', + 'navigation.open': 'Gezinmeyi aç', + 'navigation.close': 'Gezinmeyi kapat', + 'navigation.expand': 'Gezinmeyi genişlet', + 'navigation.collapse': 'Gezinmeyi daralt', +}; + +const zhCn = { + ...en, + 'nav.dashboard': '仪表盘', + 'nav.agent': '代理', + 'nav.tools': '工具', + 'nav.cron': '定时任务', + 'nav.integrations': '集成', + 'nav.memory': '记忆', + 'nav.config': '配置', + 'nav.cost': '成本跟踪', + 'nav.logs': '日志', + 'nav.doctor': '诊断', + 'dashboard.hero_title': '电光运行仪表盘', + 'dashboard.live_gateway': '在线网关', + 'dashboard.unpaired': '未配对', + 'agent.title': '代理聊天', + 'agent.placeholder': '输入消息…', + 'agent.connecting': '正在连接…', + 'agent.connected': '已连接', + 'agent.disconnected': '已断开', + 'tools.search': '搜索工具…', + 'tools.agent_tools': '代理工具', + 'tools.cli_tools': 'CLI 工具', + 'cron.add': '添加任务', + 'cron.scheduled_tasks': '定时任务', + 'integrations.title': '集成', + 'memory.add_memory': '添加记忆', + 'memory.search_entries': '搜索记忆条目…', + 'config.save': '保存', + 'config.saving': '正在保存…', + 'cost.session': '会话成本', + 'cost.daily': '每日成本', + 'cost.monthly': '每月成本', + 'logs.title': '实时日志', + 'logs.pause': '暂停', + 'logs.resume': '继续', + 'doctor.title': '系统诊断', + 'doctor.run': '运行诊断', + 'doctor.running_short': '运行中…', + 'auth.pair_button': '配对', + 'auth.enter_code': '输入终端中的一次性配对码', + 'auth.code_placeholder': '6 位代码', + 'auth.pairing_progress': '正在配对…', + 'auth.logout': '退出', + 'common.languages': '语言', + 'common.select_language': '选择语言', + 'header.dashboard_tagline': 'ZeroClaw 仪表盘', +}; + +const ja = { + ...en, + 'nav.dashboard': 'ダッシュボード', + 'nav.agent': 'エージェント', + 'nav.tools': 'ツール', + 'nav.cron': 'スケジュール', + 'nav.integrations': '連携', + 'nav.memory': 'メモリ', + 'nav.config': '設定', + 'nav.cost': 'コスト', + 'nav.logs': 'ログ', + 'nav.doctor': '診断', + 'dashboard.hero_title': 'エレクトリック・ランタイム・ダッシュボード', + 'dashboard.live_gateway': 'ライブゲートウェイ', + 'dashboard.unpaired': '未ペア', + 'agent.title': 'エージェントチャット', + 'agent.placeholder': 'メッセージを入力…', + 'agent.connecting': '接続中…', + 'agent.connected': '接続済み', + 'agent.disconnected': '切断済み', + 'tools.search': 'ツールを検索…', + 'tools.agent_tools': 'エージェントツール', + 'tools.cli_tools': 'CLI ツール', + 'cron.add': 'ジョブを追加', + 'cron.scheduled_tasks': 'スケジュールされたジョブ', + 'integrations.title': '連携', + 'memory.add_memory': 'メモリを追加', + 'memory.search_entries': 'メモリエントリを検索…', + 'config.save': '保存', + 'config.saving': '保存中…', + 'cost.session': 'セッションコスト', + 'cost.daily': '日次コスト', + 'cost.monthly': '月次コスト', + 'logs.title': 'ライブログ', + 'logs.pause': '一時停止', + 'logs.resume': '再開', + 'doctor.title': 'システム診断', + 'doctor.run': '診断を実行', + 'doctor.running_short': '実行中…', + 'auth.pair_button': 'ペアリング', + 'auth.enter_code': '端末のワンタイムペアリングコードを入力してください', + 'auth.code_placeholder': '6桁のコード', + 'auth.pairing_progress': 'ペアリング中…', + 'auth.logout': 'ログアウト', + 'common.languages': '言語', + 'common.select_language': '言語を選択', + 'header.dashboard_tagline': 'ZeroClaw ダッシュボード', +}; + +const ru = { + ...en, + 'nav.dashboard': 'Панель', + 'nav.agent': 'Агент', + 'nav.tools': 'Инструменты', + 'nav.cron': 'Задания', + 'nav.integrations': 'Интеграции', + 'nav.memory': 'Память', + 'nav.config': 'Конфигурация', + 'nav.cost': 'Расходы', + 'nav.logs': 'Логи', + 'nav.doctor': 'Диагностика', + 'dashboard.hero_title': 'Панель электрического рантайма', + 'dashboard.live_gateway': 'Живой шлюз', + 'dashboard.unpaired': 'Не сопряжено', + 'agent.title': 'Чат агента', + 'agent.placeholder': 'Введите сообщение…', + 'agent.connecting': 'Подключение…', + 'agent.connected': 'Подключено', + 'agent.disconnected': 'Отключено', + 'tools.search': 'Поиск инструментов…', + 'tools.agent_tools': 'Инструменты агента', + 'tools.cli_tools': 'CLI-инструменты', + 'cron.add': 'Добавить задачу', + 'cron.scheduled_tasks': 'Запланированные задания', + 'integrations.title': 'Интеграции', + 'memory.add_memory': 'Добавить память', + 'memory.search_entries': 'Искать записи памяти…', + 'config.save': 'Сохранить', + 'config.saving': 'Сохранение…', + 'cost.session': 'Стоимость сессии', + 'cost.daily': 'Стоимость за день', + 'cost.monthly': 'Стоимость за месяц', + 'logs.title': 'Живые логи', + 'logs.pause': 'Пауза', + 'logs.resume': 'Продолжить', + 'doctor.title': 'Диагностика системы', + 'doctor.run': 'Запустить диагностику', + 'doctor.running_short': 'Выполняется…', + 'auth.pair_button': 'Сопрячь', + 'auth.enter_code': 'Введите одноразовый код сопряжения из терминала', + 'auth.code_placeholder': '6-значный код', + 'auth.pairing_progress': 'Сопряжение…', + 'auth.logout': 'Выйти', + 'common.languages': 'Языки', + 'common.select_language': 'Выберите язык', + 'header.dashboard_tagline': 'Панель ZeroClaw', +}; + +const fr = { + ...en, + 'nav.dashboard': 'Tableau de bord', + 'nav.agent': 'Agent', + 'nav.tools': 'Outils', + 'nav.cron': 'Tâches planifiées', + 'nav.integrations': 'Intégrations', + 'nav.memory': 'Mémoire', + 'nav.config': 'Configuration', + 'nav.cost': 'Coûts', + 'nav.logs': 'Journaux', + 'nav.doctor': 'Diagnostic', + 'dashboard.hero_title': 'Tableau de bord runtime électrique', + 'dashboard.live_gateway': 'Passerelle active', + 'dashboard.unpaired': 'Non appairé', + 'agent.title': 'Chat agent', + 'agent.placeholder': 'Saisissez un message…', + 'agent.connecting': 'Connexion…', + 'agent.connected': 'Connecté', + 'agent.disconnected': 'Déconnecté', + 'tools.search': 'Rechercher des outils…', + 'tools.agent_tools': 'Outils agent', + 'tools.cli_tools': 'Outils CLI', + 'cron.add': 'Ajouter une tâche', + 'cron.scheduled_tasks': 'Tâches planifiées', + 'integrations.title': 'Intégrations', + 'memory.add_memory': 'Ajouter une mémoire', + 'memory.search_entries': 'Rechercher dans la mémoire…', + 'config.save': 'Enregistrer', + 'config.saving': 'Enregistrement…', + 'cost.session': 'Coût de session', + 'cost.daily': 'Coût journalier', + 'cost.monthly': 'Coût mensuel', + 'logs.title': 'Journaux en direct', + 'logs.pause': 'Pause', + 'logs.resume': 'Reprendre', + 'doctor.title': 'Diagnostic système', + 'doctor.run': 'Lancer le diagnostic', + 'doctor.running_short': 'Exécution…', + 'auth.pair_button': 'Associer', + 'auth.enter_code': 'Entrez le code d’appairage à usage unique affiché dans le terminal', + 'auth.code_placeholder': 'Code à 6 chiffres', + 'auth.pairing_progress': 'Appairage…', + 'auth.logout': 'Déconnexion', + 'common.languages': 'Langues', + 'common.select_language': 'Choisir la langue', + 'header.dashboard_tagline': 'Tableau de bord ZeroClaw', +}; + +const vi = { + ...en, + 'nav.dashboard': 'Bảng điều khiển', + 'nav.agent': 'Tác tử', + 'nav.tools': 'Công cụ', + 'nav.cron': 'Lịch tác vụ', + 'nav.integrations': 'Tích hợp', + 'nav.memory': 'Bộ nhớ', + 'nav.config': 'Cấu hình', + 'nav.cost': 'Chi phí', + 'nav.logs': 'Nhật ký', + 'nav.doctor': 'Chẩn đoán', + 'dashboard.hero_title': 'Bảng điều khiển runtime điện xanh', + 'dashboard.live_gateway': 'Cổng hoạt động', + 'dashboard.unpaired': 'Chưa ghép đôi', + 'agent.title': 'Trò chuyện với tác tử', + 'agent.placeholder': 'Nhập tin nhắn…', + 'agent.connecting': 'Đang kết nối…', + 'agent.connected': 'Đã kết nối', + 'agent.disconnected': 'Đã ngắt kết nối', + 'tools.search': 'Tìm công cụ…', + 'tools.agent_tools': 'Công cụ tác tử', + 'tools.cli_tools': 'Công cụ CLI', + 'cron.add': 'Thêm tác vụ', + 'cron.scheduled_tasks': 'Tác vụ đã lên lịch', + 'integrations.title': 'Tích hợp', + 'memory.add_memory': 'Thêm bộ nhớ', + 'memory.search_entries': 'Tìm trong bộ nhớ…', + 'config.save': 'Lưu', + 'config.saving': 'Đang lưu…', + 'cost.session': 'Chi phí phiên', + 'cost.daily': 'Chi phí ngày', + 'cost.monthly': 'Chi phí tháng', + 'logs.title': 'Nhật ký trực tiếp', + 'logs.pause': 'Tạm dừng', + 'logs.resume': 'Tiếp tục', + 'doctor.title': 'Chẩn đoán hệ thống', + 'doctor.run': 'Chạy chẩn đoán', + 'doctor.running_short': 'Đang chạy…', + 'auth.pair_button': 'Ghép đôi', + 'auth.enter_code': 'Nhập mã ghép đôi một lần từ terminal', + 'auth.code_placeholder': 'Mã 6 chữ số', + 'auth.pairing_progress': 'Đang ghép đôi…', + 'auth.logout': 'Đăng xuất', + 'common.languages': 'Ngôn ngữ', + 'common.select_language': 'Chọn ngôn ngữ', + 'header.dashboard_tagline': 'Bảng điều khiển ZeroClaw', +}; + + +const createLocale = (overrides: Record) => ({ + ...en, + ...overrides, +}); + +const ko = createLocale({ + 'nav.dashboard': '대시보드', + 'nav.agent': '에이전트', + 'nav.tools': '도구', + 'nav.cron': '예약 작업', + 'nav.integrations': '통합', + 'nav.memory': '메모리', + 'nav.config': '설정', + 'nav.cost': '비용 추적', + 'nav.logs': '로그', + 'nav.doctor': '진단', + 'dashboard.hero_title': '전기 런타임 대시보드', + 'agent.placeholder': '메시지를 입력하세요…', + 'tools.search': '도구 검색…', + 'cron.add': '작업 추가', + 'memory.add_memory': '메모리 추가', + 'config.save': '저장', + 'cost.token_statistics': '토큰 통계', + 'logs.title': '실시간 로그', + 'doctor.title': '시스템 진단', + 'auth.pair_button': '페어링', + 'auth.enter_code': '터미널에 표시된 일회용 페어링 코드를 입력하세요', + 'auth.code_placeholder': '6자리 코드', + 'auth.pairing_progress': '페어링 중…', + 'auth.logout': '로그아웃', + 'common.languages': '언어', + 'common.select_language': '언어 선택', + 'header.dashboard_tagline': 'ZeroClaw 대시보드', +}); + +const tl = createLocale({ + 'nav.dashboard': 'Dashboard', + 'nav.agent': 'Ahente', + 'nav.tools': 'Mga Tool', + 'nav.cron': 'Naka-iskedyul na Trabaho', + 'nav.integrations': 'Mga Integrasyon', + 'nav.memory': 'Alaala', + 'nav.config': 'Konpigurasyon', + 'nav.cost': 'Pagsubaybay sa Gastos', + 'nav.logs': 'Mga Log', + 'nav.doctor': 'Diyagnostiko', + 'dashboard.hero_title': 'Elektrikong Dashboard ng Runtime', + 'agent.placeholder': 'Mag-type ng mensahe…', + 'tools.search': 'Maghanap ng tool…', + 'cron.add': 'Magdagdag ng gawain', + 'memory.add_memory': 'Magdagdag ng alaala', + 'config.save': 'I-save', + 'cost.token_statistics': 'Estadistika ng Token', + 'logs.title': 'Mga Live Log', + 'doctor.title': 'Diyagnostiko ng System', + 'auth.pair_button': 'Ipares', + 'auth.enter_code': 'Ilagay ang isang beses na pairing code mula sa terminal', + 'auth.code_placeholder': '6-digit na code', + 'auth.pairing_progress': 'Pinapares…', + 'auth.logout': 'Mag-logout', + 'common.languages': 'Mga Wika', + 'common.select_language': 'Piliin ang wika', + 'header.dashboard_tagline': 'Dashboard ng ZeroClaw', +}); + +const es = createLocale({ + 'nav.dashboard': 'Panel', + 'nav.agent': 'Agente', + 'nav.tools': 'Herramientas', + 'nav.cron': 'Tareas programadas', + 'nav.integrations': 'Integraciones', + 'nav.memory': 'Memoria', + 'nav.config': 'Configuración', + 'nav.cost': 'Costos', + 'nav.logs': 'Registros', + 'nav.doctor': 'Diagnóstico', + 'dashboard.hero_title': 'Panel eléctrico del runtime', + 'agent.placeholder': 'Escribe un mensaje…', + 'tools.search': 'Buscar herramientas…', + 'cron.add': 'Agregar tarea', + 'memory.add_memory': 'Agregar memoria', + 'config.save': 'Guardar', + 'cost.token_statistics': 'Estadísticas de tokens', + 'logs.title': 'Registros en vivo', + 'doctor.title': 'Diagnóstico del sistema', + 'auth.pair_button': 'Vincular', + 'auth.enter_code': 'Introduce el código de vinculación de un solo uso del terminal', + 'auth.code_placeholder': 'Código de 6 dígitos', + 'auth.pairing_progress': 'Vinculando…', + 'auth.logout': 'Cerrar sesión', + 'common.languages': 'Idiomas', + 'common.select_language': 'Elegir idioma', + 'header.dashboard_tagline': 'Panel de ZeroClaw', +}); + +const pt = createLocale({ + 'nav.dashboard': 'Painel', + 'nav.agent': 'Agente', + 'nav.tools': 'Ferramentas', + 'nav.cron': 'Tarefas agendadas', + 'nav.integrations': 'Integrações', + 'nav.memory': 'Memória', + 'nav.config': 'Configuração', + 'nav.cost': 'Custos', + 'nav.logs': 'Logs', + 'nav.doctor': 'Diagnóstico', + 'dashboard.hero_title': 'Painel elétrico do runtime', + 'agent.placeholder': 'Digite uma mensagem…', + 'tools.search': 'Buscar ferramentas…', + 'cron.add': 'Adicionar tarefa', + 'memory.add_memory': 'Adicionar memória', + 'config.save': 'Salvar', + 'cost.token_statistics': 'Estatísticas de tokens', + 'logs.title': 'Logs ao vivo', + 'doctor.title': 'Diagnóstico do sistema', + 'auth.pair_button': 'Parear', + 'auth.enter_code': 'Digite o código único de pareamento mostrado no terminal', + 'auth.code_placeholder': 'Código de 6 dígitos', + 'auth.pairing_progress': 'Pareando…', + 'auth.logout': 'Sair', + 'common.languages': 'Idiomas', + 'common.select_language': 'Escolher idioma', + 'header.dashboard_tagline': 'Painel do ZeroClaw', +}); + +const it = createLocale({ + 'nav.dashboard': 'Dashboard', + 'nav.agent': 'Agente', + 'nav.tools': 'Strumenti', + 'nav.cron': 'Attività pianificate', + 'nav.integrations': 'Integrazioni', + 'nav.memory': 'Memoria', + 'nav.config': 'Configurazione', + 'nav.cost': 'Costi', + 'nav.logs': 'Log', + 'nav.doctor': 'Diagnostica', + 'dashboard.hero_title': 'Dashboard runtime elettrica', + 'agent.placeholder': 'Scrivi un messaggio…', + 'tools.search': 'Cerca strumenti…', + 'cron.add': 'Aggiungi attività', + 'memory.add_memory': 'Aggiungi memoria', + 'config.save': 'Salva', + 'cost.token_statistics': 'Statistiche token', + 'logs.title': 'Log in tempo reale', + 'doctor.title': 'Diagnostica di sistema', + 'auth.pair_button': 'Associa', + 'auth.enter_code': 'Inserisci il codice di associazione monouso dal terminale', + 'auth.code_placeholder': 'Codice a 6 cifre', + 'auth.pairing_progress': 'Associazione…', + 'auth.logout': 'Disconnetti', + 'common.languages': 'Lingue', + 'common.select_language': 'Scegli lingua', + 'header.dashboard_tagline': 'Dashboard di ZeroClaw', +}); + +const de = createLocale({ + 'nav.dashboard': 'Dashboard', + 'nav.agent': 'Agent', + 'nav.tools': 'Werkzeuge', + 'nav.cron': 'Geplante Aufgaben', + 'nav.integrations': 'Integrationen', + 'nav.memory': 'Speicher', + 'nav.config': 'Konfiguration', + 'nav.cost': 'Kosten', + 'nav.logs': 'Protokolle', + 'nav.doctor': 'Diagnose', + 'dashboard.hero_title': 'Elektrisches Runtime-Dashboard', + 'agent.placeholder': 'Nachricht eingeben…', + 'tools.search': 'Werkzeuge suchen…', + 'cron.add': 'Aufgabe hinzufügen', + 'memory.add_memory': 'Speicher hinzufügen', + 'config.save': 'Speichern', + 'cost.token_statistics': 'Token-Statistiken', + 'logs.title': 'Live-Protokolle', + 'doctor.title': 'Systemdiagnose', + 'auth.pair_button': 'Koppeln', + 'auth.enter_code': 'Geben Sie den einmaligen Kopplungscode aus dem Terminal ein', + 'auth.code_placeholder': '6-stelliger Code', + 'auth.pairing_progress': 'Kopplung…', + 'auth.logout': 'Abmelden', + 'common.languages': 'Sprachen', + 'common.select_language': 'Sprache auswählen', + 'header.dashboard_tagline': 'ZeroClaw-Dashboard', +}); + +const ar = createLocale({ + 'nav.dashboard': 'لوحة التحكم', + 'nav.agent': 'الوكيل', + 'nav.tools': 'الأدوات', + 'nav.cron': 'المهام المجدولة', + 'nav.integrations': 'التكاملات', + 'nav.memory': 'الذاكرة', + 'nav.config': 'الإعدادات', + 'nav.cost': 'تتبع التكلفة', + 'nav.logs': 'السجلات', + 'nav.doctor': 'التشخيص', + 'dashboard.hero_title': 'لوحة تشغيل كهربائية', + 'agent.placeholder': 'اكتب رسالة…', + 'tools.search': 'ابحث في الأدوات…', + 'cron.add': 'إضافة مهمة', + 'memory.add_memory': 'إضافة ذاكرة', + 'config.save': 'حفظ', + 'cost.token_statistics': 'إحصاءات الرموز', + 'logs.title': 'السجلات المباشرة', + 'doctor.title': 'تشخيص النظام', + 'auth.pair_button': 'اقتران', + 'auth.enter_code': 'أدخل رمز الاقتران لمرة واحدة من الطرفية', + 'auth.code_placeholder': 'رمز من 6 أرقام', + 'auth.pairing_progress': 'جارٍ الاقتران…', + 'auth.logout': 'تسجيل الخروج', + 'common.languages': 'اللغات', + 'common.select_language': 'اختر اللغة', + 'header.dashboard_tagline': 'لوحة ZeroClaw', +}); + +const hi = createLocale({ + 'nav.dashboard': 'डैशबोर्ड', + 'nav.agent': 'एजेंट', + 'nav.tools': 'टूल्स', + 'nav.cron': 'निर्धारित कार्य', + 'nav.integrations': 'इंटीग्रेशन', + 'nav.memory': 'मेमोरी', + 'nav.config': 'कॉन्फ़िगरेशन', + 'nav.cost': 'लागत ट्रैकर', + 'nav.logs': 'लॉग्स', + 'nav.doctor': 'जाँच', + 'dashboard.hero_title': 'इलेक्ट्रिक रनटाइम डैशबोर्ड', + 'agent.placeholder': 'संदेश लिखें…', + 'tools.search': 'टूल खोजें…', + 'cron.add': 'कार्य जोड़ें', + 'memory.add_memory': 'मेमोरी जोड़ें', + 'config.save': 'सहेजें', + 'cost.token_statistics': 'टोकन आँकड़े', + 'logs.title': 'लाइव लॉग्स', + 'doctor.title': 'सिस्टम जाँच', + 'auth.pair_button': 'पेयर करें', + 'auth.enter_code': 'टर्मिनल से एक-बार वाला पेयरिंग कोड दर्ज करें', + 'auth.code_placeholder': '6-अंकों का कोड', + 'auth.pairing_progress': 'पेयर किया जा रहा है…', + 'auth.logout': 'लॉग आउट', + 'common.languages': 'भाषाएँ', + 'common.select_language': 'भाषा चुनें', + 'header.dashboard_tagline': 'ZeroClaw डैशबोर्ड', +}); + +const bn = createLocale({ + 'nav.dashboard': 'ড্যাশবোর্ড', + 'nav.agent': 'এজেন্ট', + 'nav.tools': 'টুলস', + 'nav.cron': 'নির্ধারিত কাজ', + 'nav.integrations': 'ইন্টিগ্রেশন', + 'nav.memory': 'মেমরি', + 'nav.config': 'কনফিগারেশন', + 'nav.cost': 'খরচ ট্র্যাকার', + 'nav.logs': 'লগ', + 'nav.doctor': 'ডায়াগনস্টিক', + 'dashboard.hero_title': 'ইলেকট্রিক রানটাইম ড্যাশবোর্ড', + 'agent.placeholder': 'একটি বার্তা লিখুন…', + 'tools.search': 'টুল খুঁজুন…', + 'cron.add': 'কাজ যোগ করুন', + 'memory.add_memory': 'মেমরি যোগ করুন', + 'config.save': 'সংরক্ষণ করুন', + 'cost.token_statistics': 'টোকেন পরিসংখ্যান', + 'logs.title': 'লাইভ লগ', + 'doctor.title': 'সিস্টেম ডায়াগনস্টিক', + 'auth.pair_button': 'পেয়ার করুন', + 'auth.enter_code': 'টার্মিনাল থেকে একবারের পেয়ারিং কোড লিখুন', + 'auth.code_placeholder': '৬-সংখ্যার কোড', + 'auth.pairing_progress': 'পেয়ার করা হচ্ছে…', + 'auth.logout': 'লগ আউট', + 'common.languages': 'ভাষাসমূহ', + 'common.select_language': 'ভাষা বেছে নিন', + 'header.dashboard_tagline': 'ZeroClaw ড্যাশবোর্ড', +}); + +const he = createLocale({ + 'nav.dashboard': 'לוח מחוונים', + 'nav.agent': 'סוכן', + 'nav.tools': 'כלים', + 'nav.cron': 'משימות מתוזמנות', + 'nav.integrations': 'אינטגרציות', + 'nav.memory': 'זיכרון', + 'nav.config': 'תצורה', + 'nav.cost': 'מעקב עלויות', + 'nav.logs': 'יומנים', + 'nav.doctor': 'אבחון', + 'dashboard.hero_title': 'לוח מחוונים חשמלי של זמן הריצה', + 'agent.placeholder': 'הקלד הודעה…', + 'tools.search': 'חפש כלים…', + 'cron.add': 'הוסף משימה', + 'memory.add_memory': 'הוסף זיכרון', + 'config.save': 'שמור', + 'cost.token_statistics': 'סטטיסטיקות אסימונים', + 'logs.title': 'יומנים חיים', + 'doctor.title': 'אבחון מערכת', + 'auth.pair_button': 'התאמה', + 'auth.enter_code': 'הזן את קוד ההתאמה החד-פעמי מהמסוף', + 'auth.code_placeholder': 'קוד בן 6 ספרות', + 'auth.pairing_progress': 'מתבצעת התאמה…', + 'auth.logout': 'התנתק', + 'common.languages': 'שפות', + 'common.select_language': 'בחר שפה', + 'header.dashboard_tagline': 'לוח המחוונים של ZeroClaw', +}); + +const pl = createLocale({ + 'nav.dashboard': 'Pulpit', + 'nav.agent': 'Agent', + 'nav.tools': 'Narzędzia', + 'nav.cron': 'Zaplanowane zadania', + 'nav.integrations': 'Integracje', + 'nav.memory': 'Pamięć', + 'nav.config': 'Konfiguracja', + 'nav.cost': 'Koszty', + 'nav.logs': 'Logi', + 'nav.doctor': 'Diagnostyka', + 'dashboard.hero_title': 'Elektryczny pulpit runtime', + 'agent.placeholder': 'Wpisz wiadomość…', + 'tools.search': 'Szukaj narzędzi…', + 'cron.add': 'Dodaj zadanie', + 'memory.add_memory': 'Dodaj pamięć', + 'config.save': 'Zapisz', + 'cost.token_statistics': 'Statystyki tokenów', + 'logs.title': 'Logi na żywo', + 'doctor.title': 'Diagnostyka systemu', + 'auth.pair_button': 'Sparuj', + 'auth.enter_code': 'Wprowadź jednorazowy kod parowania z terminala', + 'auth.code_placeholder': '6-cyfrowy kod', + 'auth.pairing_progress': 'Parowanie…', + 'auth.logout': 'Wyloguj', + 'common.languages': 'Języki', + 'common.select_language': 'Wybierz język', + 'header.dashboard_tagline': 'Pulpit ZeroClaw', +}); + +const cs = createLocale({ + 'nav.dashboard': 'Nástěnka', + 'nav.agent': 'Agent', + 'nav.tools': 'Nástroje', + 'nav.cron': 'Plánované úlohy', + 'nav.integrations': 'Integrace', + 'nav.memory': 'Paměť', + 'nav.config': 'Konfigurace', + 'nav.cost': 'Náklady', + 'nav.logs': 'Logy', + 'nav.doctor': 'Diagnostika', + 'dashboard.hero_title': 'Elektrický runtime panel', + 'agent.placeholder': 'Napište zprávu…', + 'tools.search': 'Hledat nástroje…', + 'cron.add': 'Přidat úlohu', + 'memory.add_memory': 'Přidat paměť', + 'config.save': 'Uložit', + 'cost.token_statistics': 'Statistiky tokenů', + 'logs.title': 'Živé logy', + 'doctor.title': 'Diagnostika systému', + 'auth.pair_button': 'Spárovat', + 'auth.enter_code': 'Zadejte jednorázový párovací kód z terminálu', + 'auth.code_placeholder': '6místný kód', + 'auth.pairing_progress': 'Párování…', + 'auth.logout': 'Odhlásit se', + 'common.languages': 'Jazyky', + 'common.select_language': 'Vyberte jazyk', + 'header.dashboard_tagline': 'Panel ZeroClaw', +}); + +const nl = createLocale({ + 'nav.dashboard': 'Dashboard', + 'nav.agent': 'Agent', + 'nav.tools': 'Tools', + 'nav.cron': 'Geplande taken', + 'nav.integrations': 'Integraties', + 'nav.memory': 'Geheugen', + 'nav.config': 'Configuratie', + 'nav.cost': 'Kosten', + 'nav.logs': 'Logs', + 'nav.doctor': 'Diagnose', + 'dashboard.hero_title': 'Elektrisch runtime-dashboard', + 'agent.placeholder': 'Typ een bericht…', + 'tools.search': 'Tools zoeken…', + 'cron.add': 'Taak toevoegen', + 'memory.add_memory': 'Geheugen toevoegen', + 'config.save': 'Opslaan', + 'cost.token_statistics': 'Tokenstatistieken', + 'logs.title': 'Live-logs', + 'doctor.title': 'Systeemdiagnose', + 'auth.pair_button': 'Koppelen', + 'auth.enter_code': 'Voer de eenmalige koppelcode uit de terminal in', + 'auth.code_placeholder': '6-cijferige code', + 'auth.pairing_progress': 'Koppelen…', + 'auth.logout': 'Afmelden', + 'common.languages': 'Talen', + 'common.select_language': 'Kies taal', + 'header.dashboard_tagline': 'ZeroClaw-dashboard', +}); + +const uk = createLocale({ + 'nav.dashboard': 'Панель', + 'nav.agent': 'Агент', + 'nav.tools': 'Інструменти', + 'nav.cron': 'Заплановані завдання', + 'nav.integrations': 'Інтеграції', + 'nav.memory': 'Пам’ять', + 'nav.config': 'Конфігурація', + 'nav.cost': 'Витрати', + 'nav.logs': 'Журнали', + 'nav.doctor': 'Діагностика', + 'dashboard.hero_title': 'Електрична панель runtime', + 'agent.placeholder': 'Введіть повідомлення…', + 'tools.search': 'Пошук інструментів…', + 'cron.add': 'Додати завдання', + 'memory.add_memory': 'Додати пам’ять', + 'config.save': 'Зберегти', + 'cost.token_statistics': 'Статистика токенів', + 'logs.title': 'Живі журнали', + 'doctor.title': 'Діагностика системи', + 'auth.pair_button': 'З’єднати', + 'auth.enter_code': 'Введіть одноразовий код з’єднання з термінала', + 'auth.code_placeholder': '6-значний код', + 'auth.pairing_progress': 'З’єднання…', + 'auth.logout': 'Вийти', + 'common.languages': 'Мови', + 'common.select_language': 'Оберіть мову', + 'header.dashboard_tagline': 'Панель ZeroClaw', +}); + +const id = createLocale({ + 'nav.dashboard': 'Dasbor', + 'nav.agent': 'Agen', + 'nav.tools': 'Alat', + 'nav.cron': 'Tugas terjadwal', + 'nav.integrations': 'Integrasi', + 'nav.memory': 'Memori', + 'nav.config': 'Konfigurasi', + 'nav.cost': 'Biaya', + 'nav.logs': 'Log', + 'nav.doctor': 'Diagnosis', + 'dashboard.hero_title': 'Dasbor runtime elektrik', + 'agent.placeholder': 'Tulis pesan…', + 'tools.search': 'Cari alat…', + 'cron.add': 'Tambah tugas', + 'memory.add_memory': 'Tambah memori', + 'config.save': 'Simpan', + 'cost.token_statistics': 'Statistik token', + 'logs.title': 'Log langsung', + 'doctor.title': 'Diagnosis sistem', + 'auth.pair_button': 'Pasangkan', + 'auth.enter_code': 'Masukkan kode pairing sekali pakai dari terminal', + 'auth.code_placeholder': 'Kode 6 digit', + 'auth.pairing_progress': 'Sedang memasangkan…', + 'auth.logout': 'Keluar', + 'common.languages': 'Bahasa', + 'common.select_language': 'Pilih bahasa', + 'header.dashboard_tagline': 'Dasbor ZeroClaw', +}); + +const th = createLocale({ + 'nav.dashboard': 'แดชบอร์ด', + 'nav.agent': 'เอเจนต์', + 'nav.tools': 'เครื่องมือ', + 'nav.cron': 'งานที่ตั้งเวลา', + 'nav.integrations': 'การเชื่อมต่อ', + 'nav.memory': 'หน่วยความจำ', + 'nav.config': 'การกำหนดค่า', + 'nav.cost': 'ต้นทุน', + 'nav.logs': 'บันทึก', + 'nav.doctor': 'วินิจฉัย', + 'dashboard.hero_title': 'แดชบอร์ดรันไทม์ไฟฟ้า', + 'agent.placeholder': 'พิมพ์ข้อความ…', + 'tools.search': 'ค้นหาเครื่องมือ…', + 'cron.add': 'เพิ่มงาน', + 'memory.add_memory': 'เพิ่มหน่วยความจำ', + 'config.save': 'บันทึก', + 'cost.token_statistics': 'สถิติโทเค็น', + 'logs.title': 'บันทึกสด', + 'doctor.title': 'วินิจฉัยระบบ', + 'auth.pair_button': 'จับคู่', + 'auth.enter_code': 'ป้อนรหัสจับคู่แบบใช้ครั้งเดียวจากเทอร์มินัล', + 'auth.code_placeholder': 'รหัส 6 หลัก', + 'auth.pairing_progress': 'กำลังจับคู่…', + 'auth.logout': 'ออกจากระบบ', + 'common.languages': 'ภาษา', + 'common.select_language': 'เลือกภาษา', + 'header.dashboard_tagline': 'แดชบอร์ด ZeroClaw', +}); + +const ur = createLocale({ + 'nav.dashboard': 'ڈیش بورڈ', + 'nav.agent': 'ایجنٹ', + 'nav.tools': 'ٹولز', + 'nav.cron': 'شیڈول شدہ کام', + 'nav.integrations': 'انضمامات', + 'nav.memory': 'میموری', + 'nav.config': 'ترتیبات', + 'nav.cost': 'لاگت', + 'nav.logs': 'لاگز', + 'nav.doctor': 'تشخیص', + 'dashboard.hero_title': 'الیکٹرک رن ٹائم ڈیش بورڈ', + 'agent.placeholder': 'پیغام لکھیں…', + 'tools.search': 'ٹولز تلاش کریں…', + 'cron.add': 'کام شامل کریں', + 'memory.add_memory': 'میموری شامل کریں', + 'config.save': 'محفوظ کریں', + 'cost.token_statistics': 'ٹوکن کے اعدادوشمار', + 'logs.title': 'لائیو لاگز', + 'doctor.title': 'سسٹم تشخیص', + 'auth.pair_button': 'جوڑیں', + 'auth.enter_code': 'ٹرمینل سے ایک بار استعمال ہونے والا پیئرنگ کوڈ درج کریں', + 'auth.code_placeholder': '6 ہندسوں کا کوڈ', + 'auth.pairing_progress': 'جوڑا جا رہا ہے…', + 'auth.logout': 'لاگ آؤٹ', + 'common.languages': 'زبانیں', + 'common.select_language': 'زبان منتخب کریں', + 'header.dashboard_tagline': 'ZeroClaw ڈیش بورڈ', +}); + +const ro = createLocale({ + 'nav.dashboard': 'Tablou de bord', + 'nav.agent': 'Agent', + 'nav.tools': 'Unelte', + 'nav.cron': 'Sarcini programate', + 'nav.integrations': 'Integrări', + 'nav.memory': 'Memorie', + 'nav.config': 'Configurație', + 'nav.cost': 'Costuri', + 'nav.logs': 'Jurnale', + 'nav.doctor': 'Diagnostic', + 'dashboard.hero_title': 'Tablou de bord runtime electric', + 'agent.placeholder': 'Scrie un mesaj…', + 'tools.search': 'Caută unelte…', + 'cron.add': 'Adaugă sarcină', + 'memory.add_memory': 'Adaugă memorie', + 'config.save': 'Salvează', + 'cost.token_statistics': 'Statistici tokenuri', + 'logs.title': 'Jurnale live', + 'doctor.title': 'Diagnostic sistem', + 'auth.pair_button': 'Asociază', + 'auth.enter_code': 'Introdu codul unic de asociere din terminal', + 'auth.code_placeholder': 'Cod din 6 cifre', + 'auth.pairing_progress': 'Asociere…', + 'auth.logout': 'Deconectare', + 'common.languages': 'Limbi', + 'common.select_language': 'Alege limba', + 'header.dashboard_tagline': 'Tabloul ZeroClaw', +}); + +const sv = createLocale({ + 'nav.dashboard': 'Instrumentpanel', + 'nav.agent': 'Agent', + 'nav.tools': 'Verktyg', + 'nav.cron': 'Schemalagda jobb', + 'nav.integrations': 'Integrationer', + 'nav.memory': 'Minne', + 'nav.config': 'Konfiguration', + 'nav.cost': 'Kostnader', + 'nav.logs': 'Loggar', + 'nav.doctor': 'Diagnostik', + 'dashboard.hero_title': 'Elektrisk runtimepanel', + 'agent.placeholder': 'Skriv ett meddelande…', + 'tools.search': 'Sök verktyg…', + 'cron.add': 'Lägg till jobb', + 'memory.add_memory': 'Lägg till minne', + 'config.save': 'Spara', + 'cost.token_statistics': 'Tokenstatistik', + 'logs.title': 'Live-loggar', + 'doctor.title': 'Systemdiagnostik', + 'auth.pair_button': 'Para', + 'auth.enter_code': 'Ange engångskoden från terminalen', + 'auth.code_placeholder': '6-siffrig kod', + 'auth.pairing_progress': 'Parar…', + 'auth.logout': 'Logga ut', + 'common.languages': 'Språk', + 'common.select_language': 'Välj språk', + 'header.dashboard_tagline': 'ZeroClaw-panel', +}); + +const el = createLocale({ + 'nav.dashboard': 'Πίνακας ελέγχου', + 'nav.agent': 'Πράκτορας', + 'nav.tools': 'Εργαλεία', + 'nav.cron': 'Προγραμματισμένες εργασίες', + 'nav.integrations': 'Ενσωματώσεις', + 'nav.memory': 'Μνήμη', + 'nav.config': 'Ρυθμίσεις', + 'nav.cost': 'Κόστος', + 'nav.logs': 'Αρχεία καταγραφής', + 'nav.doctor': 'Διάγνωση', + 'dashboard.hero_title': 'Ηλεκτρικός πίνακας runtime', + 'agent.placeholder': 'Πληκτρολογήστε μήνυμα…', + 'tools.search': 'Αναζήτηση εργαλείων…', + 'cron.add': 'Προσθήκη εργασίας', + 'memory.add_memory': 'Προσθήκη μνήμης', + 'config.save': 'Αποθήκευση', + 'cost.token_statistics': 'Στατιστικά token', + 'logs.title': 'Ζωντανά αρχεία καταγραφής', + 'doctor.title': 'Διάγνωση συστήματος', + 'auth.pair_button': 'Σύζευξη', + 'auth.enter_code': 'Εισαγάγετε τον εφάπαξ κωδικό σύζευξης από το terminal', + 'auth.code_placeholder': '6ψήφιος κωδικός', + 'auth.pairing_progress': 'Σύζευξη…', + 'auth.logout': 'Αποσύνδεση', + 'common.languages': 'Γλώσσες', + 'common.select_language': 'Επιλέξτε γλώσσα', + 'header.dashboard_tagline': 'Πίνακας ZeroClaw', +}); + +const hu = createLocale({ + 'nav.dashboard': 'Irányítópult', + 'nav.agent': 'Ügynök', + 'nav.tools': 'Eszközök', + 'nav.cron': 'Ütemezett feladatok', + 'nav.integrations': 'Integrációk', + 'nav.memory': 'Memória', + 'nav.config': 'Konfiguráció', + 'nav.cost': 'Költségek', + 'nav.logs': 'Naplók', + 'nav.doctor': 'Diagnosztika', + 'dashboard.hero_title': 'Elektromos runtime irányítópult', + 'agent.placeholder': 'Írjon üzenetet…', + 'tools.search': 'Eszközök keresése…', + 'cron.add': 'Feladat hozzáadása', + 'memory.add_memory': 'Memória hozzáadása', + 'config.save': 'Mentés', + 'cost.token_statistics': 'Tokenstatisztika', + 'logs.title': 'Élő naplók', + 'doctor.title': 'Rendszerdiagnosztika', + 'auth.pair_button': 'Párosítás', + 'auth.enter_code': 'Adja meg a terminál egyszer használatos párosítási kódját', + 'auth.code_placeholder': '6 számjegyű kód', + 'auth.pairing_progress': 'Párosítás…', + 'auth.logout': 'Kijelentkezés', + 'common.languages': 'Nyelvek', + 'common.select_language': 'Nyelv kiválasztása', + 'header.dashboard_tagline': 'ZeroClaw irányítópult', +}); + +const fi = createLocale({ + 'nav.dashboard': 'Hallintapaneeli', + 'nav.agent': 'Agentti', + 'nav.tools': 'Työkalut', + 'nav.cron': 'Ajastetut tehtävät', + 'nav.integrations': 'Integraatiot', + 'nav.memory': 'Muisti', + 'nav.config': 'Asetukset', + 'nav.cost': 'Kustannukset', + 'nav.logs': 'Lokit', + 'nav.doctor': 'Diagnostiikka', + 'dashboard.hero_title': 'Sähköinen runtime-hallintapaneeli', + 'agent.placeholder': 'Kirjoita viesti…', + 'tools.search': 'Etsi työkaluja…', + 'cron.add': 'Lisää tehtävä', + 'memory.add_memory': 'Lisää muisti', + 'config.save': 'Tallenna', + 'cost.token_statistics': 'Token-tilastot', + 'logs.title': 'Live-lokit', + 'doctor.title': 'Järjestelmädiagnostiikka', + 'auth.pair_button': 'Yhdistä', + 'auth.enter_code': 'Syötä terminaalin kertakäyttöinen parituskoodi', + 'auth.code_placeholder': '6-numeroinen koodi', + 'auth.pairing_progress': 'Yhdistetään…', + 'auth.logout': 'Kirjaudu ulos', + 'common.languages': 'Kielet', + 'common.select_language': 'Valitse kieli', + 'header.dashboard_tagline': 'ZeroClaw-hallintapaneeli', +}); + +const da = createLocale({ + 'nav.dashboard': 'Kontrolpanel', + 'nav.agent': 'Agent', + 'nav.tools': 'Værktøjer', + 'nav.cron': 'Planlagte job', + 'nav.integrations': 'Integrationer', + 'nav.memory': 'Hukommelse', + 'nav.config': 'Konfiguration', + 'nav.cost': 'Omkostninger', + 'nav.logs': 'Logge', + 'nav.doctor': 'Diagnostik', + 'dashboard.hero_title': 'Elektrisk runtime-kontrolpanel', + 'agent.placeholder': 'Skriv en besked…', + 'tools.search': 'Søg værktøjer…', + 'cron.add': 'Tilføj job', + 'memory.add_memory': 'Tilføj hukommelse', + 'config.save': 'Gem', + 'cost.token_statistics': 'Tokenstatistik', + 'logs.title': 'Live-logge', + 'doctor.title': 'Systemdiagnostik', + 'auth.pair_button': 'Par', + 'auth.enter_code': 'Indtast engangskoden fra terminalen', + 'auth.code_placeholder': '6-cifret kode', + 'auth.pairing_progress': 'Parrer…', + 'auth.logout': 'Log ud', + 'common.languages': 'Sprog', + 'common.select_language': 'Vælg sprog', + 'header.dashboard_tagline': 'ZeroClaw-kontrolpanel', +}); + +const nb = createLocale({ + 'nav.dashboard': 'Kontrollpanel', + 'nav.agent': 'Agent', + 'nav.tools': 'Verktøy', + 'nav.cron': 'Planlagte jobber', + 'nav.integrations': 'Integrasjoner', + 'nav.memory': 'Minne', + 'nav.config': 'Konfigurasjon', + 'nav.cost': 'Kostnader', + 'nav.logs': 'Logger', + 'nav.doctor': 'Diagnostikk', + 'dashboard.hero_title': 'Elektrisk runtime-kontrollpanel', + 'agent.placeholder': 'Skriv en melding…', + 'tools.search': 'Søk etter verktøy…', + 'cron.add': 'Legg til jobb', + 'memory.add_memory': 'Legg til minne', + 'config.save': 'Lagre', + 'cost.token_statistics': 'Tokenstatistikk', + 'logs.title': 'Live-logger', + 'doctor.title': 'Systemdiagnostikk', + 'auth.pair_button': 'Koble til', + 'auth.enter_code': 'Skriv inn engangskoden fra terminalen', + 'auth.code_placeholder': '6-sifret kode', + 'auth.pairing_progress': 'Kobler til…', + 'auth.logout': 'Logg ut', + 'common.languages': 'Språk', + 'common.select_language': 'Velg språk', + 'header.dashboard_tagline': 'ZeroClaw-kontrollpanel', +}); + + +const translations: Record> = { + en, + 'zh-CN': zhCn, + ja, + ko, + vi, + tl, + es, + pt, + it, + de, + fr, + ar, + hi, + ru, + bn, + he, + pl, + cs, + nl, + tr, + uk, + id, + th, + ur, + ro, + sv, + el, + hu, + fi, + da, + nb, +}; let currentLocale: Locale = 'en'; @@ -389,34 +1629,50 @@ export function setLocale(locale: Locale): void { currentLocale = locale; } -// --------------------------------------------------------------------------- -// Translation function -// --------------------------------------------------------------------------- +export function coerceLocale(locale: string | undefined): Locale { + if (!locale) return 'en'; + const normalized = locale.toLowerCase(); + if (normalized.startsWith('zh')) return 'zh-CN'; + if (normalized.startsWith('ja')) return 'ja'; + if (normalized.startsWith('ko')) return 'ko'; + if (normalized.startsWith('vi')) return 'vi'; + if (normalized.startsWith('tl')) return 'tl'; + if (normalized.startsWith('es')) return 'es'; + if (normalized.startsWith('pt')) return 'pt'; + if (normalized.startsWith('it')) return 'it'; + if (normalized.startsWith('de')) return 'de'; + if (normalized.startsWith('fr')) return 'fr'; + if (normalized.startsWith('ar')) return 'ar'; + if (normalized.startsWith('hi')) return 'hi'; + if (normalized.startsWith('ru')) return 'ru'; + if (normalized.startsWith('bn')) return 'bn'; + if (normalized.startsWith('iw') || normalized.startsWith('he')) return 'he'; + if (normalized.startsWith('pl')) return 'pl'; + if (normalized.startsWith('cs')) return 'cs'; + if (normalized.startsWith('nl')) return 'nl'; + if (normalized.startsWith('tr')) return 'tr'; + if (normalized.startsWith('uk')) return 'uk'; + if (normalized.startsWith('id')) return 'id'; + if (normalized.startsWith('th')) return 'th'; + if (normalized.startsWith('ur')) return 'ur'; + if (normalized.startsWith('ro')) return 'ro'; + if (normalized.startsWith('sv')) return 'sv'; + if (normalized.startsWith('el')) return 'el'; + if (normalized.startsWith('hu')) return 'hu'; + if (normalized.startsWith('fi')) return 'fi'; + if (normalized.startsWith('da')) return 'da'; + if (normalized.startsWith('nb') || normalized.startsWith('no')) return 'nb'; + return 'en'; +} -/** - * Translate a key using the current locale. Returns the key itself if no - * translation is found. - */ export function t(key: string): string { return translations[currentLocale]?.[key] ?? translations.en[key] ?? key; } -/** - * Get the translation for a specific locale. Falls back to English, then to the - * raw key. - */ export function tLocale(key: string, locale: Locale): string { return translations[locale]?.[key] ?? translations.en[key] ?? key; } -// --------------------------------------------------------------------------- -// React hook -// --------------------------------------------------------------------------- - -/** - * React hook that fetches the locale from /api/status on mount and keeps the - * i18n module in sync. Returns the current locale and a `t` helper bound to it. - */ export function useLocale(): { locale: Locale; t: (key: string) => string } { const [locale, setLocaleState] = useState(currentLocale); @@ -426,9 +1682,7 @@ export function useLocale(): { locale: Locale; t: (key: string) => string } { getStatus() .then((status) => { if (cancelled) return; - const detected = status.locale?.toLowerCase().startsWith('tr') - ? 'tr' - : 'en'; + const detected = coerceLocale(status.locale); setLocale(detected); setLocaleState(detected); }) diff --git a/web/src/main.tsx b/web/src/main.tsx index 990523b67..2e607b55c 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -6,8 +6,8 @@ import './index.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - {/* Vite base '/_app/' scopes static asset URLs only; app routes stay rooted at '/' for SPA fallback. */} - + {/* Match React Router paths to Vite's public base so in-app links resolve under /_app/. */} + diff --git a/web/src/pages/AgentChat.tsx b/web/src/pages/AgentChat.tsx index 1ddfa822c..9038351b5 100644 --- a/web/src/pages/AgentChat.tsx +++ b/web/src/pages/AgentChat.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { Send, Bot, User, AlertCircle } from 'lucide-react'; import type { WsMessage } from '@/types/api'; import { WebSocketClient } from '@/lib/ws'; +import { t } from '@/lib/i18n'; interface ChatMessage { id: string; @@ -35,7 +36,7 @@ export default function AgentChat() { }; ws.onError = () => { - setError('Connection error. Attempting to reconnect...'); + setError(t('agent.connection_error')); }; ws.onMessage = (msg: WsMessage) => { @@ -67,37 +68,37 @@ export default function AgentChat() { case 'tool_call': setMessages((prev) => [ ...prev, - { - id: crypto.randomUUID(), - role: 'agent', - content: `[Tool Call] ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`, - timestamp: new Date(), - }, - ]); + { + id: crypto.randomUUID(), + role: 'agent', + content: `[${t('agent.tool_call')}] ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`, + timestamp: new Date(), + }, + ]); break; case 'tool_result': setMessages((prev) => [ ...prev, - { - id: crypto.randomUUID(), - role: 'agent', - content: `[Tool Result] ${msg.output ?? ''}`, - timestamp: new Date(), - }, - ]); + { + id: crypto.randomUUID(), + role: 'agent', + content: `[${t('agent.tool_result')}] ${msg.output ?? ''}`, + timestamp: new Date(), + }, + ]); break; case 'error': setMessages((prev) => [ ...prev, - { - id: crypto.randomUUID(), - role: 'agent', - content: `[Error] ${msg.message ?? 'Unknown error'}`, - timestamp: new Date(), - }, - ]); + { + id: crypto.randomUUID(), + role: 'agent', + content: `[${t('doctor.error')}] ${msg.message ?? t('agent.unknown_error')}`, + timestamp: new Date(), + }, + ]); setTyping(false); pendingContentRef.current = ''; break; @@ -135,7 +136,7 @@ export default function AgentChat() { setTyping(true); pendingContentRef.current = ''; } catch { - setError('Failed to send message. Please try again.'); + setError(t('agent.failed_send')); } setInput(''); @@ -164,8 +165,8 @@ export default function AgentChat() { {messages.length === 0 && (
-

ZeroClaw Agent

-

Send a message to start the conversation

+

{t('agent.empty_title')}

+

{t('agent.empty_subtitle')}

)} @@ -219,7 +220,7 @@ export default function AgentChat() {
-

Typing...

+

{t('agent.thinking')}

)} @@ -234,10 +235,11 @@ export default function AgentChat() { setInput(e.target.value)} onKeyDown={handleKeyDown} - placeholder={connected ? 'Type a message...' : 'Connecting...'} + placeholder={connected ? t('agent.placeholder') : t('agent.connecting')} disabled={!connected} className="w-full bg-gray-800 border border-gray-700 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50" /> @@ -257,7 +259,7 @@ export default function AgentChat() { }`} /> - {connected ? 'Connected' : 'Disconnected'} + {connected ? t('agent.connected') : t('agent.disconnected')}
diff --git a/web/src/pages/Config.tsx b/web/src/pages/Config.tsx index 17a4868d2..1b60468b4 100644 --- a/web/src/pages/Config.tsx +++ b/web/src/pages/Config.tsx @@ -7,6 +7,7 @@ import { ShieldAlert, } from 'lucide-react'; import { getConfig, putConfig } from '@/lib/api'; +import { t } from '@/lib/i18n'; export default function Config() { const [config, setConfig] = useState(''); @@ -31,9 +32,9 @@ export default function Config() { setSuccess(null); try { await putConfig(config); - setSuccess('Configuration saved successfully.'); + setSuccess(t('config.saved')); } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Failed to save configuration'); + setError(err instanceof Error ? err.message : t('config.error')); } finally { setSaving(false); } @@ -60,7 +61,7 @@ export default function Config() {
-

Configuration

+

{t('config.title')}

@@ -77,11 +78,10 @@ export default function Config() {

- Sensitive fields are masked + {t('config.masked_title')}

- API keys, tokens, and passwords are hidden for security. To update a - masked field, replace the entire masked value with your new value. + {t('config.masked_description')}

@@ -106,10 +106,10 @@ export default function Config() {
- TOML Configuration + {t('config.toml_configuration')} - {config.split('\n').length} lines + {config.split('\n').length} {t('config.lines')}