icebear0828 Claude Opus 4.6 commited on
Commit
a5368fd
Β·
1 Parent(s): 6f578bf

feat: add i18n support (zh/en) to dashboard

Browse files

Inline translation system with auto-detection from browser language
and localStorage persistence. Language toggle button in header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. public/dashboard.html +181 -49
public/dashboard.html CHANGED
@@ -66,15 +66,19 @@
66
  <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
67
  <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-primary"></span>
68
  </span>
69
- <span class="text-xs font-semibold text-primary">Server Online</span>
70
  </div>
 
 
 
 
71
  <!-- Theme Toggle -->
72
- <button id="themeToggle" onclick="toggleTheme()" class="p-2 rounded-lg text-slate-500 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark transition-colors" title="Toggle theme">
73
  <span id="themeIcon"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"/></svg></span>
74
  </button>
75
  <button id="addAccountBtn" onclick="startAddAccount()" class="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white text-xs font-semibold rounded-lg transition-colors shadow-sm active:scale-95">
76
  <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
77
- <span>Add Account</span>
78
  </button>
79
  </div>
80
  </div>
@@ -86,10 +90,10 @@
86
 
87
  <!-- Add Account Section (hidden by default) -->
88
  <section id="addSection" class="hidden bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-5 shadow-sm transition-colors">
89
- <p class="text-sm text-slate-500 dark:text-text-dim mb-3">If the popup shows an error or you're on a different machine, copy the full callback URL and paste it below.</p>
90
  <div class="flex gap-3">
91
- <input id="addCallbackInput" type="text" placeholder="Paste callback URL" class="flex-1 px-3 py-2.5 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-mono text-slate-600 dark:text-text-main focus:ring-2 focus:ring-primary/50 focus:border-primary outline-none transition-colors"/>
92
- <button id="addRelayBtn" onclick="submitAddRelay()" class="px-4 py-2.5 bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-medium text-slate-700 dark:text-text-main hover:bg-slate-50 dark:hover:bg-border-dark transition-colors">Submit</button>
93
  </div>
94
  <p id="addInfo" class="text-sm text-primary mt-2 hidden"></p>
95
  <p id="addError" class="text-sm text-red-500 mt-2 hidden"></p>
@@ -99,12 +103,12 @@
99
  <section class="flex flex-col gap-4">
100
  <div class="flex items-end justify-between">
101
  <div class="flex flex-col gap-1">
102
- <h2 class="text-[0.95rem] font-bold tracking-tight">Connected Accounts</h2>
103
- <p class="text-slate-500 dark:text-text-dim text-[0.8rem]">Manage your AI model proxy services and usage limits.</p>
104
  </div>
105
  </div>
106
  <div id="accountList" class="grid grid-cols-1 md:grid-cols-2 gap-4">
107
- <div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl transition-colors">Loading accounts...</div>
108
  </div>
109
  </section>
110
 
@@ -113,24 +117,24 @@
113
  <div class="flex items-center justify-between mb-6 border-b border-slate-100 dark:border-border-dark pb-4">
114
  <div class="flex items-center gap-2">
115
  <svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
116
- <h2 class="text-[0.95rem] font-bold">API Configuration</h2>
117
  </div>
118
- <button onclick="resetConfigDefaults()" class="text-xs text-slate-400 dark:text-text-dim hover:text-slate-600 dark:hover:text-text-main transition-colors">Reset to defaults</button>
119
  </div>
120
  <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
121
  <!-- Base URL -->
122
  <div class="space-y-1.5">
123
- <label class="text-xs font-semibold text-slate-700 dark:text-text-main">Base Proxy URL</label>
124
  <div class="relative flex items-center">
125
  <input id="baseUrl" class="w-full pl-3 pr-10 py-2.5 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-600 dark:text-text-main focus:ring-1 focus:ring-primary focus:border-primary outline-none transition-all" type="text" value="Loading..."/>
126
- <button onclick="copyField('baseUrl', this)" class="absolute right-2 p-1.5 text-slate-400 dark:text-text-dim hover:text-primary transition-colors rounded-md hover:bg-slate-100 dark:hover:bg-border-dark" title="Copy URL">
127
  <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
128
  </button>
129
  </div>
130
  </div>
131
  <!-- Default Model -->
132
  <div class="space-y-1.5">
133
- <label class="text-xs font-semibold text-slate-700 dark:text-text-main">Default Model</label>
134
  <div class="relative">
135
  <select id="defaultModel" class="w-full appearance-none pl-3 pr-10 py-2.5 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] text-slate-700 dark:text-text-main font-medium focus:ring-1 focus:ring-primary focus:border-primary outline-none cursor-pointer transition-colors">
136
  <option value="codex">codex</option>
@@ -142,24 +146,24 @@
142
  </div>
143
  <!-- API Key -->
144
  <div class="space-y-1.5 md:col-span-2">
145
- <label class="text-xs font-semibold text-slate-700 dark:text-text-main">Your API Key</label>
146
  <div class="relative flex items-center">
147
  <div class="absolute left-3 text-slate-400 dark:text-text-dim">
148
  <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
149
  </div>
150
  <input id="apiKey" class="w-full pl-10 pr-10 py-2.5 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-600 dark:text-text-main focus:ring-1 focus:ring-primary focus:border-primary outline-none transition-all tracking-wider" type="text" value="Loading..."/>
151
- <button onclick="copyField('apiKey', this)" class="absolute right-2 p-1.5 text-slate-400 dark:text-text-dim hover:text-primary transition-colors rounded-md hover:bg-slate-100 dark:hover:bg-border-dark" title="Copy API Key">
152
  <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
153
  </button>
154
  </div>
155
- <p class="text-xs text-slate-400 dark:text-text-dim mt-1">Use this key to authenticate requests to the proxy. Do not share it.</p>
156
  </div>
157
  </div>
158
  </section>
159
 
160
  <!-- Section 3: Integration Examples -->
161
  <section class="flex flex-col gap-4">
162
- <h2 class="text-[0.95rem] font-bold">Integration Examples</h2>
163
  <div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl overflow-hidden shadow-sm transition-colors">
164
  <!-- Top Tabs: Providers -->
165
  <div id="protocolTabs" class="flex border-b border-gray-200 dark:border-border-dark bg-slate-50/50 dark:bg-bg-dark/30">
@@ -180,7 +184,7 @@
180
  <div id="codeContainer" class="relative group rounded-lg overflow-hidden bg-[#0d1117] text-slate-300 font-mono text-xs border border-slate-800 dark:border-border-dark">
181
  <div class="absolute right-2 top-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
182
  <button onclick="copyCurrentCode()" class="flex items-center gap-1.5 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-xs font-medium transition-colors">
183
- <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg> Copy
184
  </button>
185
  </div>
186
  <div class="p-4 overflow-x-auto">
@@ -195,11 +199,137 @@
195
  <!-- Footer -->
196
  <footer class="mt-auto border-t border-gray-200 dark:border-border-dark bg-white dark:bg-card-dark py-6 transition-colors">
197
  <div class="container mx-auto px-4 text-center">
198
- <p class="text-[0.8rem] text-slate-500 dark:text-text-dim">&copy; 2025 Codex Proxy. All rights reserved.</p>
199
  </div>
200
  </footer>
201
 
202
  <script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  // ── State ──────────────────────────────────────────────────────
204
  let authData = null;
205
  let serverBaseUrl = '';
@@ -251,14 +381,14 @@ function formatNumber(n) {
251
 
252
  function statusBadge(status) {
253
  const map = {
254
- active: ['bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]', 'Active'],
255
- expired: ['bg-red-100 text-red-600 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800/30', 'Expired'],
256
- rate_limited: ['bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/30', 'Rate Limited'],
257
- refreshing: ['bg-blue-100 text-blue-600 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800/30', 'Refreshing'],
258
- disabled: ['bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30', 'Disabled'],
259
  };
260
- const [cls, label] = map[status] || map.disabled;
261
- return `<span class="px-2.5 py-1 rounded-full ${cls} text-xs font-medium border">${escapeHtml(label)}</span>`;
262
  }
263
 
264
  // ── OAuth message listener ─────────────────────────────────────
@@ -266,7 +396,7 @@ window.addEventListener('message', async (event) => {
266
  if (event.data?.type === 'oauth-callback-success') {
267
  if (addPollTimer) clearInterval(addPollTimer);
268
  document.getElementById('addSection').classList.add('hidden');
269
- showAddInfo('Account added successfully!');
270
  await loadAccounts();
271
  await loadStatus();
272
  }
@@ -289,7 +419,7 @@ function renderAccounts(accounts) {
289
 
290
  if (accounts.length === 0) {
291
  container.innerHTML = `<div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl">
292
- No accounts connected. Click "Add Account" to get started.
293
  </div>`;
294
  return;
295
  }
@@ -301,7 +431,7 @@ function renderAccounts(accounts) {
301
  const [bgColor, textColor] = avatarColors[i % avatarColors.length];
302
  const requests = usage.request_count ?? 0;
303
  const tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
304
- const plan = acct.planType || 'Free Tier';
305
 
306
  // Quota bar
307
  let quotaHtml = '';
@@ -322,24 +452,24 @@ function renderAccounts(accounts) {
322
  quotaHtml = `
323
  <div class="pt-3 mt-3 border-t border-slate-100 dark:border-border-dark">
324
  <div class="flex justify-between text-[0.78rem] mb-1.5">
325
- <span class="text-slate-500 dark:text-text-dim">Rate Limit</span>
326
  ${rl.limit_reached
327
- ? '<span class="px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-medium">Limit Reached</span>'
328
  : pct != null
329
- ? `<span class="font-medium ${pctColor}">${pct}% Used</span>`
330
- : '<span class="font-medium text-primary">OK</span>'}
331
  </div>
332
  ${pct != null ? `
333
  <div class="w-full bg-slate-100 dark:bg-border-dark rounded-full h-2 overflow-hidden">
334
  <div class="${barColor} h-2 rounded-full transition-all" style="width: ${pct}%"></div>
335
  </div>` : ''}
336
- ${resetAt ? `<p class="text-xs text-slate-400 dark:text-text-dim mt-1">Resets at ${escapeHtml(resetAt)}</p>` : ''}
337
  </div>`;
338
  }
339
 
340
  return `
341
  <div class="group bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-4 shadow-sm hover:shadow-md transition-all hover:border-primary/30 dark:hover:border-primary/50 relative">
342
- <button onclick="deleteAccount('${escapeHtml(acct.id)}')" class="absolute top-3 right-3 opacity-0 group-hover:opacity-100 p-1.5 text-slate-300 dark:text-text-dim hover:text-red-500 transition-all rounded-md hover:bg-red-50 dark:hover:bg-red-900/20" title="Delete account">
343
  <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
344
  </button>
345
  <div class="flex justify-between items-start mb-4">
@@ -354,11 +484,11 @@ function renderAccounts(accounts) {
354
  </div>
355
  <div class="space-y-2">
356
  <div class="flex justify-between text-[0.78rem]">
357
- <span class="text-slate-500 dark:text-text-dim">Total Requests</span>
358
  <span class="font-medium">${formatNumber(requests)}</span>
359
  </div>
360
  <div class="flex justify-between text-[0.78rem]">
361
- <span class="text-slate-500 dark:text-text-dim">Tokens Used</span>
362
  <span class="font-medium">${formatNumber(tokens)}</span>
363
  </div>
364
  </div>
@@ -368,18 +498,18 @@ function renderAccounts(accounts) {
368
  }
369
 
370
  async function deleteAccount(id) {
371
- if (!confirm('Remove this account?')) return;
372
  try {
373
  const resp = await fetch('/auth/accounts/' + encodeURIComponent(id), { method: 'DELETE' });
374
  if (!resp.ok) {
375
  const data = await resp.json();
376
- alert(data.error || 'Failed to delete account.');
377
  return;
378
  }
379
  await loadAccounts();
380
  await loadStatus();
381
  } catch (err) {
382
- alert('Network error: ' + err.message);
383
  }
384
  }
385
 
@@ -411,7 +541,7 @@ async function startAddAccount() {
411
  const data = await resp.json();
412
 
413
  if (!resp.ok || !data.authUrl) {
414
- throw new Error(data.error || 'Failed to start login');
415
  }
416
 
417
  window.open(data.authUrl, 'oauth_add', 'width=600,height=700,scrollbars=yes');
@@ -427,7 +557,7 @@ async function startAddAccount() {
427
  if ((d.accounts?.length || 0) > prevCount) {
428
  clearInterval(addPollTimer);
429
  document.getElementById('addSection').classList.add('hidden');
430
- showAddInfo('Account added successfully!');
431
  await loadAccounts();
432
  await loadStatus();
433
  }
@@ -446,11 +576,11 @@ async function submitAddRelay() {
446
  document.getElementById('addInfo').classList.add('hidden');
447
  document.getElementById('addError').classList.add('hidden');
448
 
449
- if (!callbackUrl) { showAddError('Please paste the callback URL'); return; }
450
 
451
  const btn = document.getElementById('addRelayBtn');
452
  btn.disabled = true;
453
- btn.textContent = 'Submitting...';
454
 
455
  try {
456
  const resp = await fetch('/auth/code-relay', {
@@ -464,16 +594,16 @@ async function submitAddRelay() {
464
  if (addPollTimer) clearInterval(addPollTimer);
465
  document.getElementById('addSection').classList.add('hidden');
466
  document.getElementById('addCallbackInput').value = '';
467
- showAddInfo('Account added successfully!');
468
  await loadAccounts();
469
  await loadStatus();
470
  } else {
471
- showAddError(data.error || 'Failed to exchange code');
472
  }
473
  } catch (err) {
474
- showAddError('Network error: ' + err.message);
475
  } finally {
476
- btn.textContent = 'Submit';
477
  btn.disabled = false;
478
  }
479
  }
@@ -730,6 +860,8 @@ function setupReactiveBindings() {
730
  }
731
 
732
  // ── Init ───────────────────────────────────────────────────────
 
 
733
  loadStatus();
734
  setupReactiveBindings();
735
  loadAccounts();
 
66
  <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
67
  <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-primary"></span>
68
  </span>
69
+ <span class="text-xs font-semibold text-primary" data-i18n="serverOnline">Server Online</span>
70
  </div>
71
+ <!-- Language Toggle -->
72
+ <button id="langToggle" onclick="toggleLang()" class="p-2 rounded-lg text-slate-500 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark transition-colors" title="δΈ­/EN">
73
+ <span class="text-xs font-bold">EN</span>
74
+ </button>
75
  <!-- Theme Toggle -->
76
+ <button id="themeToggle" onclick="toggleTheme()" class="p-2 rounded-lg text-slate-500 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark transition-colors" data-i18n-title="toggleTheme" title="Toggle theme">
77
  <span id="themeIcon"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"/></svg></span>
78
  </button>
79
  <button id="addAccountBtn" onclick="startAddAccount()" class="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white text-xs font-semibold rounded-lg transition-colors shadow-sm active:scale-95">
80
  <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
81
+ <span data-i18n="addAccount">Add Account</span>
82
  </button>
83
  </div>
84
  </div>
 
90
 
91
  <!-- Add Account Section (hidden by default) -->
92
  <section id="addSection" class="hidden bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-5 shadow-sm transition-colors">
93
+ <p class="text-sm text-slate-500 dark:text-text-dim mb-3" data-i18n="addAccountHint">If the popup shows an error or you're on a different machine, copy the full callback URL and paste it below.</p>
94
  <div class="flex gap-3">
95
+ <input id="addCallbackInput" type="text" data-i18n-placeholder="pasteCallback" placeholder="Paste callback URL" class="flex-1 px-3 py-2.5 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-mono text-slate-600 dark:text-text-main focus:ring-2 focus:ring-primary/50 focus:border-primary outline-none transition-colors"/>
96
+ <button id="addRelayBtn" onclick="submitAddRelay()" class="px-4 py-2.5 bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-medium text-slate-700 dark:text-text-main hover:bg-slate-50 dark:hover:bg-border-dark transition-colors" data-i18n="submit">Submit</button>
97
  </div>
98
  <p id="addInfo" class="text-sm text-primary mt-2 hidden"></p>
99
  <p id="addError" class="text-sm text-red-500 mt-2 hidden"></p>
 
103
  <section class="flex flex-col gap-4">
104
  <div class="flex items-end justify-between">
105
  <div class="flex flex-col gap-1">
106
+ <h2 class="text-[0.95rem] font-bold tracking-tight" data-i18n="connectedAccounts">Connected Accounts</h2>
107
+ <p class="text-slate-500 dark:text-text-dim text-[0.8rem]" data-i18n="connectedAccountsDesc">Manage your AI model proxy services and usage limits.</p>
108
  </div>
109
  </div>
110
  <div id="accountList" class="grid grid-cols-1 md:grid-cols-2 gap-4">
111
+ <div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl transition-colors" data-i18n="loadingAccounts">Loading accounts...</div>
112
  </div>
113
  </section>
114
 
 
117
  <div class="flex items-center justify-between mb-6 border-b border-slate-100 dark:border-border-dark pb-4">
118
  <div class="flex items-center gap-2">
119
  <svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
120
+ <h2 class="text-[0.95rem] font-bold" data-i18n="apiConfig">API Configuration</h2>
121
  </div>
122
+ <button onclick="resetConfigDefaults()" class="text-xs text-slate-400 dark:text-text-dim hover:text-slate-600 dark:hover:text-text-main transition-colors" data-i18n="resetDefaults">Reset to defaults</button>
123
  </div>
124
  <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
125
  <!-- Base URL -->
126
  <div class="space-y-1.5">
127
+ <label class="text-xs font-semibold text-slate-700 dark:text-text-main" data-i18n="baseProxyUrl">Base Proxy URL</label>
128
  <div class="relative flex items-center">
129
  <input id="baseUrl" class="w-full pl-3 pr-10 py-2.5 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-600 dark:text-text-main focus:ring-1 focus:ring-primary focus:border-primary outline-none transition-all" type="text" value="Loading..."/>
130
+ <button onclick="copyField('baseUrl', this)" class="absolute right-2 p-1.5 text-slate-400 dark:text-text-dim hover:text-primary transition-colors rounded-md hover:bg-slate-100 dark:hover:bg-border-dark" data-i18n-title="copyUrl" title="Copy URL">
131
  <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
132
  </button>
133
  </div>
134
  </div>
135
  <!-- Default Model -->
136
  <div class="space-y-1.5">
137
+ <label class="text-xs font-semibold text-slate-700 dark:text-text-main" data-i18n="defaultModel">Default Model</label>
138
  <div class="relative">
139
  <select id="defaultModel" class="w-full appearance-none pl-3 pr-10 py-2.5 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] text-slate-700 dark:text-text-main font-medium focus:ring-1 focus:ring-primary focus:border-primary outline-none cursor-pointer transition-colors">
140
  <option value="codex">codex</option>
 
146
  </div>
147
  <!-- API Key -->
148
  <div class="space-y-1.5 md:col-span-2">
149
+ <label class="text-xs font-semibold text-slate-700 dark:text-text-main" data-i18n="yourApiKey">Your API Key</label>
150
  <div class="relative flex items-center">
151
  <div class="absolute left-3 text-slate-400 dark:text-text-dim">
152
  <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
153
  </div>
154
  <input id="apiKey" class="w-full pl-10 pr-10 py-2.5 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-600 dark:text-text-main focus:ring-1 focus:ring-primary focus:border-primary outline-none transition-all tracking-wider" type="text" value="Loading..."/>
155
+ <button onclick="copyField('apiKey', this)" class="absolute right-2 p-1.5 text-slate-400 dark:text-text-dim hover:text-primary transition-colors rounded-md hover:bg-slate-100 dark:hover:bg-border-dark" data-i18n-title="copyApiKey" title="Copy API Key">
156
  <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
157
  </button>
158
  </div>
159
+ <p class="text-xs text-slate-400 dark:text-text-dim mt-1" data-i18n="apiKeyHint">Use this key to authenticate requests to the proxy. Do not share it.</p>
160
  </div>
161
  </div>
162
  </section>
163
 
164
  <!-- Section 3: Integration Examples -->
165
  <section class="flex flex-col gap-4">
166
+ <h2 class="text-[0.95rem] font-bold" data-i18n="integrationExamples">Integration Examples</h2>
167
  <div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl overflow-hidden shadow-sm transition-colors">
168
  <!-- Top Tabs: Providers -->
169
  <div id="protocolTabs" class="flex border-b border-gray-200 dark:border-border-dark bg-slate-50/50 dark:bg-bg-dark/30">
 
184
  <div id="codeContainer" class="relative group rounded-lg overflow-hidden bg-[#0d1117] text-slate-300 font-mono text-xs border border-slate-800 dark:border-border-dark">
185
  <div class="absolute right-2 top-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
186
  <button onclick="copyCurrentCode()" class="flex items-center gap-1.5 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-xs font-medium transition-colors">
187
+ <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg> <span data-i18n="copy">Copy</span>
188
  </button>
189
  </div>
190
  <div class="p-4 overflow-x-auto">
 
199
  <!-- Footer -->
200
  <footer class="mt-auto border-t border-gray-200 dark:border-border-dark bg-white dark:bg-card-dark py-6 transition-colors">
201
  <div class="container mx-auto px-4 text-center">
202
+ <p class="text-[0.8rem] text-slate-500 dark:text-text-dim" data-i18n="footer">&copy; 2025 Codex Proxy. All rights reserved.</p>
203
  </div>
204
  </footer>
205
 
206
  <script>
207
+ // ── i18n ──────────────────────────────────────────────────────
208
+ const LANG = {
209
+ en: {
210
+ serverOnline: "Server Online",
211
+ addAccount: "Add Account",
212
+ toggleTheme: "Toggle theme",
213
+ connectedAccounts: "Connected Accounts",
214
+ connectedAccountsDesc: "Manage your AI model proxy services and usage limits.",
215
+ loadingAccounts: "Loading accounts...",
216
+ noAccounts: 'No accounts connected. Click "Add Account" to get started.',
217
+ deleteAccount: "Delete account",
218
+ removeConfirm: "Remove this account?",
219
+ accountAdded: "Account added successfully!",
220
+ active: "Active",
221
+ expired: "Expired",
222
+ rateLimited: "Rate Limited",
223
+ refreshing: "Refreshing",
224
+ disabled: "Disabled",
225
+ freeTier: "Free Tier",
226
+ totalRequests: "Total Requests",
227
+ tokensUsed: "Tokens Used",
228
+ rateLimit: "Rate Limit",
229
+ limitReached: "Limit Reached",
230
+ used: "Used",
231
+ ok: "OK",
232
+ resetsAt: "Resets at",
233
+ apiConfig: "API Configuration",
234
+ resetDefaults: "Reset to defaults",
235
+ baseProxyUrl: "Base Proxy URL",
236
+ defaultModel: "Default Model",
237
+ yourApiKey: "Your API Key",
238
+ apiKeyHint: "Use this key to authenticate requests to the proxy. Do not share it.",
239
+ copyUrl: "Copy URL",
240
+ copyApiKey: "Copy API Key",
241
+ integrationExamples: "Integration Examples",
242
+ copy: "Copy",
243
+ addAccountHint: "If the popup shows an error or you're on a different machine, copy the full callback URL and paste it below.",
244
+ pasteCallback: "Paste callback URL",
245
+ submit: "Submit",
246
+ submitting: "Submitting...",
247
+ pleasePassCallback: "Please paste the callback URL",
248
+ failedStartLogin: "Failed to start login",
249
+ failedExchangeCode: "Failed to exchange code",
250
+ failedDeleteAccount: "Failed to delete account.",
251
+ networkError: "Network error: ",
252
+ footer: "\u00a9 2025 Codex Proxy. All rights reserved.",
253
+ },
254
+ zh: {
255
+ serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
256
+ addAccount: "\u6dfb\u52a0\u8d26\u6237",
257
+ toggleTheme: "\u5207\u6362\u4e3b\u9898",
258
+ connectedAccounts: "\u5df2\u8fde\u63a5\u8d26\u6237",
259
+ connectedAccountsDesc: "\u7ba1\u7406\u4f60\u7684 AI \u6a21\u578b\u4ee3\u7406\u670d\u52a1\u548c\u7528\u91cf\u9650\u5236\u3002",
260
+ loadingAccounts: "\u6b63\u5728\u52a0\u8f7d\u8d26\u6237...",
261
+ noAccounts: "\u6682\u65e0\u5df2\u8fde\u63a5\u7684\u8d26\u6237\u3002\u70b9\u51fb\u300c\u6dfb\u52a0\u8d26\u6237\u300d\u5f00\u59cb\u4f7f\u7528\u3002",
262
+ deleteAccount: "\u5220\u9664\u8d26\u6237",
263
+ removeConfirm: "\u786e\u5b9a\u8981\u79fb\u9664\u6b64\u8d26\u6237\u5417\uff1f",
264
+ accountAdded: "\u8d26\u6237\u6dfb\u52a0\u6210\u529f\uff01",
265
+ active: "\u6d3b\u8dc3",
266
+ expired: "\u5df2\u8fc7\u671f",
267
+ rateLimited: "\u5df2\u9650\u901f",
268
+ refreshing: "\u5237\u65b0\u4e2d",
269
+ disabled: "\u5df2\u7981\u7528",
270
+ freeTier: "\u514d\u8d39\u7248",
271
+ totalRequests: "\u603b\u8bf7\u6c42\u6570",
272
+ tokensUsed: "Token \u7528\u91cf",
273
+ rateLimit: "\u901f\u7387\u9650\u5236",
274
+ limitReached: "\u5df2\u8fbe\u4e0a\u9650",
275
+ used: "\u5df2\u4f7f\u7528",
276
+ ok: "\u6b63\u5e38",
277
+ resetsAt: "\u91cd\u7f6e\u65f6\u95f4",
278
+ apiConfig: "API \u914d\u7f6e",
279
+ resetDefaults: "\u6062\u590d\u9ed8\u8ba4",
280
+ baseProxyUrl: "\u4ee3\u7406 URL",
281
+ defaultModel: "\u9ed8\u8ba4\u6a21\u578b",
282
+ yourApiKey: "API \u5bc6\u94a5",
283
+ apiKeyHint: "\u4f7f\u7528\u6b64\u5bc6\u94a5\u5411\u4ee3\u7406\u53d1\u9001\u8ba4\u8bc1\u8bf7\u6c42\uff0c\u8bf7\u52ff\u6cc4\u9732\u3002",
284
+ copyUrl: "\u590d\u5236 URL",
285
+ copyApiKey: "\u590d\u5236 API \u5bc6\u94a5",
286
+ integrationExamples: "\u96c6\u6210\u793a\u4f8b",
287
+ copy: "\u590d\u5236",
288
+ addAccountHint: "\u5982\u679c\u5f39\u7a97\u663e\u793a\u9519\u8bef\u6216\u4f60\u5728\u5176\u4ed6\u8bbe\u5907\u4e0a\uff0c\u8bf7\u590d\u5236\u5b8c\u6574\u7684\u56de\u8c03 URL \u7c98\u8d34\u5230\u4e0b\u65b9\u3002",
289
+ pasteCallback: "\u7c98\u8d34\u56de\u8c03 URL",
290
+ submit: "\u63d0\u4ea4",
291
+ submitting: "\u63d0\u4ea4\u4e2d...",
292
+ pleasePassCallback: "\u8bf7\u7c98\u8d34\u56de\u8c03 URL",
293
+ failedStartLogin: "\u767b\u5f55\u542f\u52a8\u5931\u8d25",
294
+ failedExchangeCode: "\u6388\u6743\u7801\u4ea4\u6362\u5931\u8d25",
295
+ failedDeleteAccount: "\u5220\u9664\u8d26\u6237\u5931\u8d25\u3002",
296
+ networkError: "\u7f51\u7edc\u9519\u8bef\uff1a",
297
+ footer: "\u00a9 2025 Codex Proxy\u3002\u4fdd\u7559\u6240\u6709\u6743\u5229\u3002",
298
+ },
299
+ };
300
+
301
+ let currentLangCode = localStorage.getItem('codex-proxy-lang')
302
+ || (navigator.language.startsWith('zh') ? 'zh' : 'en');
303
+
304
+ function t(key) {
305
+ return LANG[currentLangCode]?.[key] ?? LANG.en[key] ?? key;
306
+ }
307
+
308
+ function applyI18n() {
309
+ document.querySelectorAll('[data-i18n]').forEach(el => {
310
+ el.textContent = t(el.dataset.i18n);
311
+ });
312
+ document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
313
+ el.placeholder = t(el.dataset.i18nPlaceholder);
314
+ });
315
+ document.querySelectorAll('[data-i18n-title]').forEach(el => {
316
+ el.title = t(el.dataset.i18nTitle);
317
+ });
318
+ }
319
+
320
+ function toggleLang() {
321
+ currentLangCode = currentLangCode === 'en' ? 'zh' : 'en';
322
+ localStorage.setItem('codex-proxy-lang', currentLangCode);
323
+ applyI18n();
324
+ updateLangToggle();
325
+ loadAccounts();
326
+ }
327
+
328
+ function updateLangToggle() {
329
+ const el = document.getElementById('langToggle');
330
+ if (el) el.querySelector('span').textContent = currentLangCode === 'en' ? 'EN' : '\u4e2d';
331
+ }
332
+
333
  // ── State ──────────────────────────────────────────────────────
334
  let authData = null;
335
  let serverBaseUrl = '';
 
381
 
382
  function statusBadge(status) {
383
  const map = {
384
+ active: ['bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]', 'active'],
385
+ expired: ['bg-red-100 text-red-600 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800/30', 'expired'],
386
+ rate_limited: ['bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/30', 'rateLimited'],
387
+ refreshing: ['bg-blue-100 text-blue-600 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800/30', 'refreshing'],
388
+ disabled: ['bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30', 'disabled'],
389
  };
390
+ const [cls, key] = map[status] || map.disabled;
391
+ return `<span class="px-2.5 py-1 rounded-full ${cls} text-xs font-medium border">${escapeHtml(t(key))}</span>`;
392
  }
393
 
394
  // ── OAuth message listener ─────────────────────────────────────
 
396
  if (event.data?.type === 'oauth-callback-success') {
397
  if (addPollTimer) clearInterval(addPollTimer);
398
  document.getElementById('addSection').classList.add('hidden');
399
+ showAddInfo(t('accountAdded'));
400
  await loadAccounts();
401
  await loadStatus();
402
  }
 
419
 
420
  if (accounts.length === 0) {
421
  container.innerHTML = `<div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl">
422
+ ${escapeHtml(t('noAccounts'))}
423
  </div>`;
424
  return;
425
  }
 
431
  const [bgColor, textColor] = avatarColors[i % avatarColors.length];
432
  const requests = usage.request_count ?? 0;
433
  const tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
434
+ const plan = acct.planType || t('freeTier');
435
 
436
  // Quota bar
437
  let quotaHtml = '';
 
452
  quotaHtml = `
453
  <div class="pt-3 mt-3 border-t border-slate-100 dark:border-border-dark">
454
  <div class="flex justify-between text-[0.78rem] mb-1.5">
455
+ <span class="text-slate-500 dark:text-text-dim">${escapeHtml(t('rateLimit'))}</span>
456
  ${rl.limit_reached
457
+ ? `<span class="px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-medium">${escapeHtml(t('limitReached'))}</span>`
458
  : pct != null
459
+ ? `<span class="font-medium ${pctColor}">${pct}% ${escapeHtml(t('used'))}</span>`
460
+ : `<span class="font-medium text-primary">${escapeHtml(t('ok'))}</span>`}
461
  </div>
462
  ${pct != null ? `
463
  <div class="w-full bg-slate-100 dark:bg-border-dark rounded-full h-2 overflow-hidden">
464
  <div class="${barColor} h-2 rounded-full transition-all" style="width: ${pct}%"></div>
465
  </div>` : ''}
466
+ ${resetAt ? `<p class="text-xs text-slate-400 dark:text-text-dim mt-1">${escapeHtml(t('resetsAt'))} ${escapeHtml(resetAt)}</p>` : ''}
467
  </div>`;
468
  }
469
 
470
  return `
471
  <div class="group bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-4 shadow-sm hover:shadow-md transition-all hover:border-primary/30 dark:hover:border-primary/50 relative">
472
+ <button onclick="deleteAccount('${escapeHtml(acct.id)}')" class="absolute top-3 right-3 opacity-0 group-hover:opacity-100 p-1.5 text-slate-300 dark:text-text-dim hover:text-red-500 transition-all rounded-md hover:bg-red-50 dark:hover:bg-red-900/20" title="${escapeHtml(t('deleteAccount'))}">
473
  <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
474
  </button>
475
  <div class="flex justify-between items-start mb-4">
 
484
  </div>
485
  <div class="space-y-2">
486
  <div class="flex justify-between text-[0.78rem]">
487
+ <span class="text-slate-500 dark:text-text-dim">${escapeHtml(t('totalRequests'))}</span>
488
  <span class="font-medium">${formatNumber(requests)}</span>
489
  </div>
490
  <div class="flex justify-between text-[0.78rem]">
491
+ <span class="text-slate-500 dark:text-text-dim">${escapeHtml(t('tokensUsed'))}</span>
492
  <span class="font-medium">${formatNumber(tokens)}</span>
493
  </div>
494
  </div>
 
498
  }
499
 
500
  async function deleteAccount(id) {
501
+ if (!confirm(t('removeConfirm'))) return;
502
  try {
503
  const resp = await fetch('/auth/accounts/' + encodeURIComponent(id), { method: 'DELETE' });
504
  if (!resp.ok) {
505
  const data = await resp.json();
506
+ alert(data.error || t('failedDeleteAccount'));
507
  return;
508
  }
509
  await loadAccounts();
510
  await loadStatus();
511
  } catch (err) {
512
+ alert(t('networkError') + err.message);
513
  }
514
  }
515
 
 
541
  const data = await resp.json();
542
 
543
  if (!resp.ok || !data.authUrl) {
544
+ throw new Error(data.error || t('failedStartLogin'));
545
  }
546
 
547
  window.open(data.authUrl, 'oauth_add', 'width=600,height=700,scrollbars=yes');
 
557
  if ((d.accounts?.length || 0) > prevCount) {
558
  clearInterval(addPollTimer);
559
  document.getElementById('addSection').classList.add('hidden');
560
+ showAddInfo(t('accountAdded'));
561
  await loadAccounts();
562
  await loadStatus();
563
  }
 
576
  document.getElementById('addInfo').classList.add('hidden');
577
  document.getElementById('addError').classList.add('hidden');
578
 
579
+ if (!callbackUrl) { showAddError(t('pleasePassCallback')); return; }
580
 
581
  const btn = document.getElementById('addRelayBtn');
582
  btn.disabled = true;
583
+ btn.textContent = t('submitting');
584
 
585
  try {
586
  const resp = await fetch('/auth/code-relay', {
 
594
  if (addPollTimer) clearInterval(addPollTimer);
595
  document.getElementById('addSection').classList.add('hidden');
596
  document.getElementById('addCallbackInput').value = '';
597
+ showAddInfo(t('accountAdded'));
598
  await loadAccounts();
599
  await loadStatus();
600
  } else {
601
+ showAddError(data.error || t('failedExchangeCode'));
602
  }
603
  } catch (err) {
604
+ showAddError(t('networkError') + err.message);
605
  } finally {
606
+ btn.textContent = t('submit');
607
  btn.disabled = false;
608
  }
609
  }
 
860
  }
861
 
862
  // ── Init ───────────────────────────────────────────────────────
863
+ applyI18n();
864
+ updateLangToggle();
865
  loadStatus();
866
  setupReactiveBindings();
867
  loadAccounts();