@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.
- package/CHANGELOG.md +6 -0
- package/dist/app.d.ts +15 -1
- package/dist/app.js +15 -1
- package/dist/app.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/routes/pm.js +51 -1
- package/dist/routes/pm.js.map +1 -1
- package/dist/services/pm-runner.d.ts +16 -0
- package/dist/services/pm-runner.js +21 -0
- package/dist/services/pm-runner.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +2 -2
- package/public/index.html +32 -28
- package/public/src/app.js +14 -0
- package/public/src/app.js.map +1 -1
- package/public/src/app.ts +15 -0
- package/public/src/components/modals.js +5 -3
- package/public/src/components/modals.js.map +1 -1
- package/public/src/components/modals.ts +6 -3
- package/public/src/i18n/de.json +117 -0
- package/public/src/i18n/en.json +117 -0
- package/public/src/i18n/es.json +117 -0
- package/public/src/i18n/zh.json +117 -0
- package/public/src/i18n.js +287 -0
- package/public/src/i18n.js.map +1 -0
- package/public/src/i18n.ts +306 -0
- package/public/src/sw.ts +3 -0
- package/public/src/views/auth.js +8 -7
- package/public/src/views/auth.js.map +1 -1
- package/public/src/views/auth.ts +8 -7
- package/public/src/views/settings.js +73 -48
- package/public/src/views/settings.js.map +1 -1
- package/public/src/views/settings.ts +77 -48
- package/public/sw.js +3 -0
|
@@ -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)"
|
|
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"
|
|
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"
|
|
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"
|
|
45
|
-
<input class="form-input" id="settings-display-name" type="text" value="${escHtml(u.display_name||u.email||'')}" placeholder="
|
|
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"
|
|
49
|
-
<input class="form-input" type="text" value="${escHtml(u.email||'')}" disabled style="opacity:0.6;cursor:not-allowed" aria-label="
|
|
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="
|
|
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"
|
|
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"
|
|
61
|
-
<input class="form-input" id="settings-current-pw" type="password" placeholder="
|
|
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"
|
|
65
|
-
<input class="form-input" id="settings-new-pw" type="password" placeholder="
|
|
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"
|
|
69
|
-
<input class="form-input" id="settings-confirm-pw" type="password" placeholder="
|
|
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="
|
|
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"
|
|
78
|
-
${u.has_github_token ? `<span style="font-size:12px;color:var(--status-closed)"
|
|
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)"
|
|
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
|
|
85
|
-
<li
|
|
86
|
-
<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"
|
|
91
|
-
<input class="form-input" id="settings-github-token" type="password" placeholder="${u.has_github_token ? '
|
|
92
|
-
<div style="font-size:11px;color:var(--text-muted);margin-top:4px"
|
|
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="
|
|
97
|
-
${u.has_github_token ? `<button class="btn btn-danger btn-sm" onclick="window.__app.clearGitHubToken()" aria-label="
|
|
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 = '
|
|
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 = '
|
|
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('
|
|
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 = '
|
|
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 = '
|
|
141
|
-
if (newPassword !== confirmPassword) { if (errEl) { errEl.textContent = '
|
|
142
|
-
if (newPassword.length < 6) { if (errEl) { errEl.textContent = '
|
|
143
|
-
if (btn) { btn.disabled = true; const sp = btn.querySelector('span'); if (sp) sp.textContent = '
|
|
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('
|
|
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 = '
|
|
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 = '
|
|
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 = '
|
|
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('
|
|
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 = '
|
|
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('
|
|
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('
|
|
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 };
|