@unbrained/pm-web 2026.7.13 → 2026.7.17-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ import { api } from '../api.js';
6
6
  import { escHtml } from '../utils.js';
7
7
  import { toast } from '../components/toast.js';
8
8
  import { confirmDialog } from '../components/modals.js';
9
+ import { t, translateError, localeDate, getLocale, SUPPORTED_LOCALES, type SupportedLocale } from '../i18n.js';
9
10
 
10
11
  function avatarInitial(name: string): string {
11
12
  return (name.trim()[0] || '?').toUpperCase();
@@ -18,20 +19,45 @@ function avatarBg(seed: string): string {
18
19
  return `hsl(${hue},55%,45%)`;
19
20
  }
20
21
 
22
+ /** Compile-time-complete mapping from supported locale → settings label key. */
23
+ const LOCALE_LABEL_KEYS: Record<SupportedLocale, string> = {
24
+ en: 'settings.languageEn',
25
+ de: 'settings.languageDe',
26
+ es: 'settings.languageEs',
27
+ zh: 'settings.languageZh',
28
+ };
29
+
21
30
  export function renderSettingsView(): void {
22
31
  const el = document.getElementById('content-settings');
23
32
  if (!el) return;
24
33
  const u = state.user || ({} as any);
25
34
  const createdInfo = u.created_at
26
- ? `<span style="display:block;margin-top:4px;font-size:12px;color:var(--text-muted)">Account created ${new Date(u.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</span>`
35
+ ? `<span style="display:block;margin-top:4px;font-size:12px;color:var(--text-muted)">${escHtml(t('settings.accountCreated', { date: localeDate(u.created_at, { year: 'numeric', month: 'long', day: 'numeric' }) }))}</span>`
27
36
  : '';
37
+ const currentLocale = getLocale();
38
+ const langOptions = SUPPORTED_LOCALES.map((loc) => {
39
+ const label = t(LOCALE_LABEL_KEYS[loc]);
40
+ return `<option value="${loc}"${loc === currentLocale ? ' selected' : ''}>${escHtml(label)}</option>`;
41
+ }).join('');
28
42
  el.innerHTML = `
29
43
  <div class="page-header">
30
- <div><div class="page-title">Settings</div><div class="page-subtitle">Manage your profile and account</div></div>
44
+ <div><div class="page-title">${escHtml(t('settings.title'))}</div><div class="page-subtitle">${escHtml(t('settings.subtitle'))}</div></div>
31
45
  </div>
32
46
  <div style="max-width:560px;display:flex;flex-direction:column;gap:20px">
33
47
  <div class="card">
34
- <div class="card-header"><div class="card-title">Profile</div></div>
48
+ <div class="card-header"><div class="card-title">${escHtml(t('settings.language'))}</div></div>
49
+ <div class="card-body">
50
+ <div class="form-group">
51
+ <label class="form-label" for="settings-language">${escHtml(t('settings.language'))}</label>
52
+ <select class="form-input" id="settings-language" onchange="window.__app.changeLanguage(this.value)" aria-label="${escHtml(t('settings.language'))}">
53
+ ${langOptions}
54
+ </select>
55
+ <div style="font-size:12px;color:var(--text-muted);margin-top:6px">${escHtml(t('settings.languageHint'))}</div>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ <div class="card">
60
+ <div class="card-header"><div class="card-title">${escHtml(t('settings.profile'))}</div></div>
35
61
  <div class="card-body">
36
62
  <div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
37
63
  <div style="width:64px;height:64px;border-radius:50%;background:${avatarBg(u.email||u.display_name||'?')};display:flex;align-items:center;justify-content:center;font-size:24px;font-weight:700;color:#fff;flex-shrink:0;user-select:none" aria-hidden="true">${escHtml(avatarInitial(u.display_name||u.email||'?'))}</div>
@@ -41,60 +67,60 @@ export function renderSettingsView(): void {
41
67
  </div>
42
68
  </div>
43
69
  <div class="form-group">
44
- <label class="form-label" for="settings-display-name">Display Name</label>
45
- <input class="form-input" id="settings-display-name" type="text" value="${escHtml(u.display_name||u.email||'')}" placeholder="Your display name" aria-label="Display name">
70
+ <label class="form-label" for="settings-display-name">${escHtml(t('settings.displayName'))}</label>
71
+ <input class="form-input" id="settings-display-name" type="text" value="${escHtml(u.display_name||u.email||'')}" placeholder="${escHtml(t('settings.displayNamePlaceholder'))}" aria-label="${escHtml(t('settings.displayName'))}">
46
72
  </div>
47
73
  <div class="form-group">
48
- <label class="form-label">Email</label>
49
- <input class="form-input" type="text" value="${escHtml(u.email||'')}" disabled style="opacity:0.6;cursor:not-allowed" aria-label="Email address (read only)">
74
+ <label class="form-label">${escHtml(t('settings.email'))}</label>
75
+ <input class="form-input" type="text" value="${escHtml(u.email||'')}" disabled style="opacity:0.6;cursor:not-allowed" aria-label="${escHtml(t('settings.emailReadonly'))}">
50
76
  ${createdInfo}
51
77
  </div>
52
78
  <div class="form-error" id="settings-profile-error" style="display:none" role="alert"></div>
53
- <button class="btn btn-primary btn-sm" id="settings-profile-btn" onclick="window.__app.saveProfile()" aria-label="Save profile changes"><span>Save Profile</span></button>
79
+ <button class="btn btn-primary btn-sm" id="settings-profile-btn" onclick="window.__app.saveProfile()" aria-label="${escHtml(t('settings.saveProfile'))}"><span>${escHtml(t('settings.saveProfile'))}</span></button>
54
80
  </div>
55
81
  </div>
56
82
  <div class="card">
57
- <div class="card-header"><div class="card-title">Change Password</div></div>
83
+ <div class="card-header"><div class="card-title">${escHtml(t('settings.changePassword'))}</div></div>
58
84
  <div class="card-body">
59
85
  <div class="form-group">
60
- <label class="form-label" for="settings-current-pw">Current Password</label>
61
- <input class="form-input" id="settings-current-pw" type="password" placeholder="Current password" autocomplete="current-password" aria-label="Current password">
86
+ <label class="form-label" for="settings-current-pw">${escHtml(t('settings.currentPassword'))}</label>
87
+ <input class="form-input" id="settings-current-pw" type="password" placeholder="${escHtml(t('settings.currentPasswordPlaceholder'))}" autocomplete="current-password" aria-label="${escHtml(t('settings.currentPassword'))}">
62
88
  </div>
63
89
  <div class="form-group">
64
- <label class="form-label" for="settings-new-pw">New Password</label>
65
- <input class="form-input" id="settings-new-pw" type="password" placeholder="New password" autocomplete="new-password" aria-label="New password">
90
+ <label class="form-label" for="settings-new-pw">${escHtml(t('settings.newPassword'))}</label>
91
+ <input class="form-input" id="settings-new-pw" type="password" placeholder="${escHtml(t('settings.newPasswordPlaceholder'))}" autocomplete="new-password" aria-label="${escHtml(t('settings.newPassword'))}">
66
92
  </div>
67
93
  <div class="form-group">
68
- <label class="form-label" for="settings-confirm-pw">Confirm New Password</label>
69
- <input class="form-input" id="settings-confirm-pw" type="password" placeholder="Confirm new password" autocomplete="new-password" aria-label="Confirm new password">
94
+ <label class="form-label" for="settings-confirm-pw">${escHtml(t('settings.confirmPassword'))}</label>
95
+ <input class="form-input" id="settings-confirm-pw" type="password" placeholder="${escHtml(t('settings.confirmPasswordPlaceholder'))}" autocomplete="new-password" aria-label="${escHtml(t('settings.confirmPassword'))}">
70
96
  </div>
71
97
  <div class="form-error" id="settings-pw-error" style="display:none" role="alert"></div>
72
- <button class="btn btn-primary btn-sm" id="settings-pw-btn" onclick="window.__app.changePassword()" aria-label="Change password"><span>Change Password</span></button>
98
+ <button class="btn btn-primary btn-sm" id="settings-pw-btn" onclick="window.__app.changePassword()" aria-label="${escHtml(t('settings.changePassword'))}"><span>${escHtml(t('settings.changePassword'))}</span></button>
73
99
  </div>
74
100
  </div>
75
101
  <div class="card">
76
102
  <div class="card-header">
77
- <div class="card-title">GitHub Token</div>
78
- ${u.has_github_token ? `<span style="font-size:12px;color:var(--status-closed)">✓ Token configured</span>` : `<span style="font-size:12px;color:var(--text-muted)">No token set</span>`}
103
+ <div class="card-title">${escHtml(t('settings.githubToken'))}</div>
104
+ ${u.has_github_token ? `<span style="font-size:12px;color:var(--status-closed)">${escHtml(t('settings.tokenConfigured'))}</span>` : `<span style="font-size:12px;color:var(--text-muted)">${escHtml(t('settings.tokenNotSet'))}</span>`}
79
105
  </div>
80
106
  <div class="card-body">
81
107
  <div style="margin-bottom:12px;padding:10px 12px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius);font-size:12px;color:var(--text-secondary)">
82
- <strong style="color:var(--text-primary)">How to get a token:</strong>
108
+ <strong style="color:var(--text-primary)">${escHtml(t('settings.howToGet'))}</strong>
83
109
  <ol style="margin-top:6px;padding-left:18px;line-height:1.8">
84
- <li>Go to GitHub → Settings → Developer settings → Personal access tokens</li>
85
- <li>Generate a new token with <code style="font-family:monospace;background:var(--bg-base);padding:0 3px;border-radius:3px">repo</code> scope</li>
86
- <li>Paste the token below and click Save</li>
110
+ <li>${escHtml(t('settings.tokenStep1'))}</li>
111
+ <li>${escHtml(t('settings.tokenStep2'))}</li>
112
+ <li>${escHtml(t('settings.tokenStep3'))}</li>
87
113
  </ol>
88
114
  </div>
89
115
  <div class="form-group">
90
- <label class="form-label" for="settings-github-token">Personal Access Token (PAT)</label>
91
- <input class="form-input" id="settings-github-token" type="password" placeholder="${u.has_github_token ? 'Leave blank to keep current token' : 'ghp_…'}" autocomplete="off" aria-label="GitHub personal access token">
92
- <div style="font-size:11px;color:var(--text-muted);margin-top:4px">Used for GitHub integration. Needs <code style="font-family:monospace;background:var(--bg-input);padding:0 3px;border-radius:3px">repo</code> scope.</div>
116
+ <label class="form-label" for="settings-github-token">${escHtml(t('settings.tokenLabel'))}</label>
117
+ <input class="form-input" id="settings-github-token" type="password" placeholder="${escHtml(u.has_github_token ? t('settings.tokenPlaceholder.keep') : t('settings.tokenPlaceholder.set'))}" autocomplete="off" aria-label="${escHtml(t('settings.tokenLabel'))}">
118
+ <div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('settings.tokenHint')}</div>
93
119
  </div>
94
120
  <div class="form-error" id="settings-github-error" style="display:none" role="alert"></div>
95
121
  <div style="display:flex;gap:8px">
96
- <button class="btn btn-primary btn-sm" id="settings-github-btn" onclick="window.__app.saveGitHubToken()" aria-label="Save GitHub token"><span>Save Token</span></button>
97
- ${u.has_github_token ? `<button class="btn btn-danger btn-sm" onclick="window.__app.clearGitHubToken()" aria-label="Clear GitHub token">Clear Token</button>` : ''}
122
+ <button class="btn btn-primary btn-sm" id="settings-github-btn" onclick="window.__app.saveGitHubToken()" aria-label="${escHtml(t('settings.saveToken'))}"><span>${escHtml(t('settings.saveToken'))}</span></button>
123
+ ${u.has_github_token ? `<button class="btn btn-danger btn-sm" onclick="window.__app.clearGitHubToken()" aria-label="${escHtml(t('settings.clearToken'))}">${escHtml(t('settings.clearToken'))}</button>` : ''}
98
124
  </div>
99
125
  </div>
100
126
  </div>
@@ -105,9 +131,9 @@ export async function saveProfile(): Promise<void> {
105
131
  const displayName = (document.getElementById('settings-display-name') as HTMLInputElement | null)?.value?.trim() || '';
106
132
  const errEl = document.getElementById('settings-profile-error') as HTMLElement | null;
107
133
  const btn = document.getElementById('settings-profile-btn') as HTMLButtonElement | null;
108
- if (!displayName) { if (errEl) { errEl.textContent = 'Display name cannot be empty'; errEl.style.display = 'block'; } return; }
134
+ if (!displayName) { if (errEl) { errEl.textContent = t('settings.displayNameEmpty'); errEl.style.display = 'block'; } return; }
109
135
  if (errEl) errEl.style.display = 'none';
110
- if (btn) { btn.disabled = true; const sp = btn.querySelector('span'); if (sp) sp.textContent = 'Saving…'; }
136
+ if (btn) { btn.disabled = true; const sp = btn.querySelector('span'); if (sp) sp.textContent = t('settings.saving'); }
111
137
  try {
112
138
  const data = await api('PATCH','/auth/profile',{displayName});
113
139
  if ((data as any).user) {
@@ -121,12 +147,12 @@ export async function saveProfile(): Promise<void> {
121
147
  if (avatarEl) avatarEl.textContent = initials;
122
148
  const nameEl = document.getElementById('user-name-display');
123
149
  if (nameEl) nameEl.textContent = u.display_name||u.email;
124
- toast('Profile saved','success');
150
+ toast(t('settings.profileSaved'),'success');
125
151
  renderSettingsView();
126
152
  } catch(err: unknown) {
127
- if (errEl) { errEl.textContent = err instanceof Error ? err.message : String(err); errEl.style.display = 'block'; }
153
+ if (errEl) { errEl.textContent = translateError(err instanceof Error ? err.message : String(err)); errEl.style.display = 'block'; }
128
154
  } finally {
129
- if (btn) { btn.disabled = false; const sp = btn.querySelector('span'); if (sp) sp.textContent = 'Save Profile'; }
155
+ if (btn) { btn.disabled = false; const sp = btn.querySelector('span'); if (sp) sp.textContent = t('settings.saveProfile'); }
130
156
  }
131
157
  }
132
158
 
@@ -137,13 +163,13 @@ export async function changePassword(): Promise<void> {
137
163
  const errEl = document.getElementById('settings-pw-error') as HTMLElement | null;
138
164
  const btn = document.getElementById('settings-pw-btn') as HTMLButtonElement | null;
139
165
  if (errEl) errEl.style.display = 'none';
140
- if (!currentPassword || !newPassword || !confirmPassword) { if (errEl) { errEl.textContent = 'All fields are required'; errEl.style.display = 'block'; } return; }
141
- if (newPassword !== confirmPassword) { if (errEl) { errEl.textContent = 'New passwords do not match'; errEl.style.display = 'block'; } return; }
142
- if (newPassword.length < 6) { if (errEl) { errEl.textContent = 'New password must be at least 6 characters'; errEl.style.display = 'block'; } return; }
143
- if (btn) { btn.disabled = true; const sp = btn.querySelector('span'); if (sp) sp.textContent = 'Changing…'; }
166
+ if (!currentPassword || !newPassword || !confirmPassword) { if (errEl) { errEl.textContent = t('settings.allFieldsRequired'); errEl.style.display = 'block'; } return; }
167
+ if (newPassword !== confirmPassword) { if (errEl) { errEl.textContent = t('settings.passwordsDoNotMatch'); errEl.style.display = 'block'; } return; }
168
+ if (newPassword.length < 6) { if (errEl) { errEl.textContent = t('settings.passwordTooShort'); errEl.style.display = 'block'; } return; }
169
+ if (btn) { btn.disabled = true; const sp = btn.querySelector('span'); if (sp) sp.textContent = t('settings.changing'); }
144
170
  try {
145
171
  await api('POST','/auth/change-password',{currentPassword,newPassword});
146
- toast('Password changed successfully','success');
172
+ toast(t('settings.passwordChanged'),'success');
147
173
  const curEl = document.getElementById('settings-current-pw') as HTMLInputElement | null;
148
174
  const newEl = document.getElementById('settings-new-pw') as HTMLInputElement | null;
149
175
  const confEl = document.getElementById('settings-confirm-pw') as HTMLInputElement | null;
@@ -151,9 +177,9 @@ export async function changePassword(): Promise<void> {
151
177
  if (newEl) newEl.value = '';
152
178
  if (confEl) confEl.value = '';
153
179
  } catch(err: unknown) {
154
- if (errEl) { errEl.textContent = err instanceof Error ? err.message : String(err); errEl.style.display = 'block'; }
180
+ if (errEl) { errEl.textContent = translateError(err instanceof Error ? err.message : String(err)); errEl.style.display = 'block'; }
155
181
  } finally {
156
- if (btn) { btn.disabled = false; const sp = btn.querySelector('span'); if (sp) sp.textContent = 'Change Password'; }
182
+ if (btn) { btn.disabled = false; const sp = btn.querySelector('span'); if (sp) sp.textContent = t('settings.changePassword'); }
157
183
  }
158
184
  }
159
185
 
@@ -161,30 +187,33 @@ export async function saveGitHubToken(): Promise<void> {
161
187
  const token = (document.getElementById('settings-github-token') as HTMLInputElement | null)?.value?.trim() || '';
162
188
  const errEl = document.getElementById('settings-github-error') as HTMLElement | null;
163
189
  const btn = document.getElementById('settings-github-btn') as HTMLButtonElement | null;
164
- if (!token) { if (errEl) { errEl.textContent = 'Please enter a token'; errEl.style.display = 'block'; } return; }
190
+ if (!token) { if (errEl) { errEl.textContent = t('settings.tokenEmpty'); errEl.style.display = 'block'; } return; }
165
191
  if (errEl) errEl.style.display = 'none';
166
- if (btn) { btn.disabled = true; const sp = btn.querySelector('span'); if (sp) sp.textContent = 'Saving…'; }
192
+ if (btn) { btn.disabled = true; const sp = btn.querySelector('span'); if (sp) sp.textContent = t('settings.saving'); }
167
193
  try {
168
194
  const data = await api('PATCH','/auth/github-token',{token});
169
195
  state.user!.has_github_token = (data as any).hasToken !== undefined ? (data as any).hasToken : true;
170
- toast('GitHub token saved','success');
196
+ toast(t('settings.tokenSaved'),'success');
171
197
  renderSettingsView();
172
198
  } catch(err: unknown) {
173
- if (errEl) { errEl.textContent = err instanceof Error ? err.message : String(err); errEl.style.display = 'block'; }
199
+ if (errEl) { errEl.textContent = translateError(err instanceof Error ? err.message : String(err)); errEl.style.display = 'block'; }
174
200
  } finally {
175
- if (btn) { btn.disabled = false; const sp = btn.querySelector('span'); if (sp) sp.textContent = 'Save Token'; }
201
+ if (btn) { btn.disabled = false; const sp = btn.querySelector('span'); if (sp) sp.textContent = t('settings.saveToken'); }
176
202
  }
177
203
  }
178
204
 
179
205
  export function clearGitHubToken(): void {
180
- confirmDialog('Clear GitHub Token?', 'Your GitHub integration will stop working.', async () => {
206
+ confirmDialog(t('settings.clearTokenTitle'), t('settings.clearTokenBody'), async () => {
181
207
  try {
182
208
  const data = await api('PATCH','/auth/github-token',{token:''});
183
209
  state.user!.has_github_token = false;
184
- toast('GitHub token cleared','success');
210
+ toast(t('settings.tokenCleared'),'success');
185
211
  renderSettingsView();
186
212
  } catch(err: unknown) {
187
- toast(err instanceof Error ? err.message : String(err),'error');
213
+ toast(translateError(err instanceof Error ? err.message : String(err)),'error');
188
214
  }
189
- });
215
+ }, false, { cancel: t('dialog.cancel'), confirm: t('dialog.confirm') });
190
216
  }
217
+
218
+ // Re-export so type-only re-exports stay usable if extended later.
219
+ export type { SupportedLocale };
package/public/sw.js CHANGED
@@ -35,6 +35,9 @@ const STATIC_ASSETS = [
35
35
  '/src/components/toast.js',
36
36
  '/src/constants.js',
37
37
  '/src/filters.js',
38
+ '/src/i18n.js',
39
+ '/src/i18n/de.json',
40
+ '/src/i18n/en.json',
38
41
  '/src/state.js',
39
42
  '/src/theme.js',
40
43
  '/src/types.js',