careermate 0.0.5 → 0.0.7
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/AGENTS.md +13 -10
- package/INSTALL.md +16 -15
- package/README.md +24 -24
- package/THIRD_PARTY_NOTICES.md +2 -3
- package/bin/careermate.mjs +8 -1
- package/dist/career-os/README.md +12 -6
- package/dist/career-os/eop/career-description.md +139 -0
- package/dist/career-os/eop/cover-letter.md +10 -5
- package/dist/career-os/eop/fit-analysis.md +16 -6
- package/dist/career-os/eop/interview-prep.md +209 -21
- package/dist/career-os/eop/job-analysis.md +6 -4
- package/dist/career-os/eop/profile-extraction.md +5 -4
- package/dist/career-os/knowledge/cover-letter.md +5 -4
- package/dist/career-os/knowledge/fit-matching.md +8 -8
- package/dist/career-os/knowledge/human-writing.md +2 -0
- package/dist/career-os/knowledge/interview-behavioral.md +76 -8
- package/dist/career-os/knowledge/interview-technical.md +106 -4
- package/dist/career-os/knowledge/linkedin-profile.md +1 -1
- package/dist/career-os/knowledge/networking-referrals.md +1 -1
- package/dist/career-os/knowledge/offer-evaluation-decision.md +10 -10
- package/dist/career-os/knowledge/portfolio.md +3 -3
- package/dist/career-os/knowledge/verifiers/ats-compat.md +8 -4
- package/dist/career-os/knowledge/verifiers/human-voice.md +4 -2
- package/dist/career-os/knowledge/verifiers/recency-staleness.md +1 -1
- package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
- package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -1
- package/dist/doctor.mjs +692 -71
- package/dist/mcp.mjs +710 -71
- package/dist/migrate.mjs +52 -1
- package/dist/public/app.js +42 -16
- package/dist/public/i18n/en/applications.js +23 -0
- package/dist/public/i18n/en/common.js +22 -0
- package/dist/public/i18n/en/documents.js +133 -0
- package/dist/public/i18n/en/home.js +75 -0
- package/dist/public/i18n/en/interview.js +69 -0
- package/dist/public/i18n/en/jobs.js +152 -0
- package/dist/public/i18n/en/nav.js +18 -0
- package/dist/public/i18n/en/profile.js +201 -0
- package/dist/public/i18n/en/settings.js +127 -0
- package/dist/public/i18n/en/status.js +22 -0
- package/dist/public/i18n/en.js +17 -0
- package/dist/public/i18n/ko/applications.js +23 -0
- package/dist/public/i18n/ko/common.js +22 -0
- package/dist/public/i18n/ko/documents.js +133 -0
- package/dist/public/i18n/ko/home.js +63 -0
- package/dist/public/i18n/ko/interview.js +69 -0
- package/dist/public/i18n/ko/jobs.js +152 -0
- package/dist/public/i18n/ko/nav.js +18 -0
- package/dist/public/i18n/ko/profile.js +201 -0
- package/dist/public/i18n/ko/settings.js +127 -0
- package/dist/public/i18n/ko/status.js +23 -0
- package/dist/public/i18n/ko.js +18 -0
- package/dist/public/i18n.js +55 -0
- package/dist/public/index.html +1 -0
- package/dist/public/lib.js +24 -17
- package/dist/public/pages/applications.js +17 -21
- package/dist/public/pages/documents.js +153 -107
- package/dist/public/pages/home.js +88 -53
- package/dist/public/pages/interview.js +105 -51
- package/dist/public/pages/jobs.js +330 -106
- package/dist/public/pages/profile.js +157 -152
- package/dist/public/pages/settings.js +154 -102
- package/dist/public/styles.css +28 -1
- package/dist/web.mjs +495 -75
- package/docs/FAQ.md +23 -3
- package/docs/MCP_TOOLS.md +22 -13
- package/docs/START_WORKFLOW.md +3 -3
- package/docs/SUPPORTED_AI_APPS.md +13 -0
- package/docs/WORKFLOWS.md +77 -20
- package/docs/career-os/README.md +12 -6
- package/docs/career-os/eop/career-description.md +139 -0
- package/docs/career-os/eop/cover-letter.md +10 -5
- package/docs/career-os/eop/fit-analysis.md +16 -6
- package/docs/career-os/eop/interview-prep.md +209 -21
- package/docs/career-os/eop/job-analysis.md +6 -4
- package/docs/career-os/eop/profile-extraction.md +5 -4
- package/docs/career-os/knowledge/cover-letter.md +5 -4
- package/docs/career-os/knowledge/fit-matching.md +8 -8
- package/docs/career-os/knowledge/human-writing.md +2 -0
- package/docs/career-os/knowledge/interview-behavioral.md +76 -8
- package/docs/career-os/knowledge/interview-technical.md +106 -4
- package/docs/career-os/knowledge/linkedin-profile.md +1 -1
- package/docs/career-os/knowledge/networking-referrals.md +1 -1
- package/docs/career-os/knowledge/offer-evaluation-decision.md +10 -10
- package/docs/career-os/knowledge/portfolio.md +3 -3
- package/docs/career-os/knowledge/verifiers/ats-compat.md +8 -4
- package/docs/career-os/knowledge/verifiers/human-voice.md +4 -2
- package/docs/career-os/knowledge/verifiers/recency-staleness.md +1 -1
- package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
- package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -1
- package/package.json +2 -2
- package/packages/core/src/onboarding.ts +2 -1
- package/packages/core/src/services.ts +134 -17
- package/packages/core/src/summary.ts +59 -1
- package/packages/core/src/verify/lint.ts +4 -0
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/repositories.ts +0 -0
- package/packages/db/src/schema.ts +17 -0
- package/packages/exporters/src/index.ts +28 -17
- package/packages/knowledge/src/index.ts +55 -9
- package/packages/mcp-tools/src/tools.ts +250 -24
- package/packages/mcp-tools/src/update.ts +30 -15
- package/packages/parsers/src/documents.ts +12 -1
- package/packages/prompts/src/humanize.ts +46 -0
- package/packages/prompts/src/index.ts +5 -1
- package/packages/prompts/src/install.ts +4 -4
- package/packages/prompts/src/onboarding.ts +4 -4
- package/packages/prompts/src/system.ts +9 -7
- package/packages/shared/src/enums.ts +14 -4
- package/packages/shared/src/schemas.ts +41 -0
- package/packages/workflows/src/definitions.ts +45 -8
- package/scripts/init.ts +284 -13
- package/dist/site/analytics.js +0 -97
- package/dist/site/favicon.svg +0 -1
- package/dist/site/fonts/PretendardVariable.woff2 +0 -0
- package/dist/site/icon-180.png +0 -0
- package/dist/site/icon-512.png +0 -0
- package/dist/site/index.html +0 -465
- package/dist/site/landing.js +0 -163
- package/dist/site/llms-install.txt +0 -218
- package/dist/site/robots.txt +0 -4
- package/dist/site/sitemap.xml +0 -21
- package/dist/site/start.html +0 -356
- package/dist/site/style.css +0 -665
- package/dist/site/vercel.json +0 -20
|
@@ -4,18 +4,11 @@ import {
|
|
|
4
4
|
el, get, post, icon, navigate, Card, Btn, IconBtn, Input,
|
|
5
5
|
openModal, copyText, downloadUrl, fmtRelative, toastOk, toastError, mount,
|
|
6
6
|
} from '/lib.js';
|
|
7
|
+
import { t, getLang, setLang, onLangChange } from '/i18n.js';
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
applications: '지원', interview_preps: '면접 준비',
|
|
12
|
-
};
|
|
13
|
-
const BACKUP_COUNT_LABELS = {
|
|
14
|
-
...COUNT_LABELS,
|
|
15
|
-
cover_letter_versions: '자소서 버전',
|
|
16
|
-
fit_analyses: '적합도 분석',
|
|
17
|
-
activities: '활동',
|
|
18
|
-
};
|
|
9
|
+
// Data keys whose human labels come from t('settings.count.<key>').
|
|
10
|
+
const COUNT_KEYS = ['profile', 'experiences', 'projects', 'skills', 'documents', 'cover_letters', 'jobs', 'applications', 'interview_preps'];
|
|
11
|
+
const countLabel = (key) => t('settings.count.' + key);
|
|
19
12
|
|
|
20
13
|
export async function render(ctx) {
|
|
21
14
|
ctx.setActions([]);
|
|
@@ -25,9 +18,11 @@ export async function render(ctx) {
|
|
|
25
18
|
DataLocation(info),
|
|
26
19
|
Connection(info),
|
|
27
20
|
Theme(),
|
|
21
|
+
LanguageSwitcher(),
|
|
28
22
|
VerifyMode(info),
|
|
29
23
|
MyData(info.counts),
|
|
30
24
|
Backup(backups, async () => { await ctx.refreshNav(); await render(ctx); }),
|
|
25
|
+
Support(),
|
|
31
26
|
DangerZone(ctx),
|
|
32
27
|
);
|
|
33
28
|
|
|
@@ -42,53 +37,57 @@ function kvRow(label, value, copyable) {
|
|
|
42
37
|
el('div', { class: 'flex between gap-3' },
|
|
43
38
|
el('span', { class: 'truncate tnum', style: { fontFamily: 'var(--mono, ui-monospace, monospace)' }, title: value }, value || '—'),
|
|
44
39
|
copyable && value
|
|
45
|
-
? IconBtn('copy', { title: '
|
|
40
|
+
? IconBtn('copy', { title: t('settings.copy'), onClick: () => copyText(value) })
|
|
46
41
|
: null)),
|
|
47
42
|
];
|
|
48
43
|
}
|
|
49
44
|
|
|
50
45
|
function DataLocation(info) {
|
|
51
46
|
const kv = el('dl', { class: 'kv' },
|
|
52
|
-
...kvRow('
|
|
53
|
-
...kvRow('
|
|
54
|
-
...kvRow('
|
|
55
|
-
...kvRow('
|
|
47
|
+
...kvRow(t('settings.dataLocation.dataDir'), info.data_dir, true),
|
|
48
|
+
...kvRow(t('settings.dataLocation.database'), info.db_path, true),
|
|
49
|
+
...kvRow(t('settings.dataLocation.appVersion'), info.version, false),
|
|
50
|
+
...kvRow(t('settings.dataLocation.nodeVersion'), info.node_version, false),
|
|
56
51
|
);
|
|
57
52
|
|
|
58
53
|
const privacy = el('div', { class: 'callout callout--privacy' },
|
|
59
54
|
icon('lock'),
|
|
60
55
|
el('div', {},
|
|
61
|
-
el('div', { class: 'callout__title' }, '
|
|
56
|
+
el('div', { class: 'callout__title' }, t('settings.dataLocation.privacyTitle')),
|
|
62
57
|
el('div', { class: 'callout__body' },
|
|
63
|
-
'
|
|
58
|
+
t('settings.dataLocation.privacyBody'))));
|
|
64
59
|
|
|
65
60
|
return Card({
|
|
66
|
-
title: '
|
|
61
|
+
title: t('settings.dataLocation.title'),
|
|
67
62
|
body: el('div', { class: 'stack-3' }, kv, privacy),
|
|
68
63
|
});
|
|
69
64
|
}
|
|
70
65
|
|
|
71
66
|
/* ---------------------------------------------------------- 연결 상태 */
|
|
72
67
|
function Connection(info) {
|
|
68
|
+
const isDemo = !!info.demo;
|
|
69
|
+
|
|
73
70
|
async function createShortcut() {
|
|
74
71
|
try {
|
|
75
72
|
const { shortcut } = await post('/api/settings/dashboard-shortcut', { open: true });
|
|
76
|
-
toastOk('
|
|
73
|
+
toastOk(isDemo ? t('settings.connection.shortcutDemoToast') : t('settings.connection.shortcutToast'));
|
|
77
74
|
openModal({
|
|
78
|
-
title: '
|
|
75
|
+
title: isDemo ? t('settings.connection.shortcutDemoTitle') : t('settings.connection.shortcutTitle'),
|
|
79
76
|
body: el('div', { class: 'stack-3' },
|
|
80
77
|
el('div', { class: 'callout' },
|
|
81
78
|
icon('info'),
|
|
82
79
|
el('div', {},
|
|
83
|
-
el('div', { class: 'callout__title' }, '
|
|
80
|
+
el('div', { class: 'callout__title' }, isDemo ? t('settings.connection.shortcutDemoCalloutTitle') : t('settings.connection.shortcutCalloutTitle')),
|
|
84
81
|
el('div', { class: 'callout__body' },
|
|
85
|
-
|
|
82
|
+
isDemo
|
|
83
|
+
? t('settings.connection.shortcutDemoCalloutBody')
|
|
84
|
+
: t('settings.connection.shortcutCalloutBody')))),
|
|
86
85
|
el('dl', { class: 'kv' },
|
|
87
|
-
...kvRow('
|
|
88
|
-
...kvRow('
|
|
86
|
+
...kvRow(t('settings.connection.shortcutFolder'), shortcut.shortcut_dir, true),
|
|
87
|
+
...kvRow(t('settings.connection.shortcutLauncher'), shortcut.launcher_path, true))),
|
|
89
88
|
footer: (close) => [
|
|
90
|
-
Btn('
|
|
91
|
-
Btn('
|
|
89
|
+
Btn(t('settings.copyPath'), { icon: 'copy', onClick: () => copyText(shortcut.launcher_path) }),
|
|
90
|
+
Btn(t('settings.confirm'), { variant: 'primary', onClick: close }),
|
|
92
91
|
],
|
|
93
92
|
});
|
|
94
93
|
} catch (e) {
|
|
@@ -98,23 +97,23 @@ function Connection(info) {
|
|
|
98
97
|
|
|
99
98
|
const dashRow = el('div', { class: 'settings-action-row flex between gap-3', style: { padding: '4px 0' } },
|
|
100
99
|
el('div', { class: 'settings-action-row__main flex gap-3', style: { alignItems: 'center' } },
|
|
101
|
-
el('span', { class: 'badge badge--final_passed' }, el('span', { class: 'dot' }), '
|
|
100
|
+
el('span', { class: 'badge badge--final_passed' }, el('span', { class: 'dot' }), t('settings.connection.running')),
|
|
102
101
|
el('div', {},
|
|
103
|
-
el('div', { class: 'strong' }, '
|
|
102
|
+
el('div', { class: 'strong' }, t('settings.connection.dashboard')),
|
|
104
103
|
el('div', { class: 'muted text-sm tnum' }, info.url || '—'))),
|
|
105
|
-
Btn('
|
|
104
|
+
Btn(t('settings.connection.createShortcut'), { icon: 'copy', sm: true, variant: 'ghost', onClick: createShortcut }));
|
|
106
105
|
|
|
107
106
|
const mcpRow = el('div', { class: 'settings-action-row flex between gap-3', style: { padding: '4px 0', alignItems: 'flex-start' } },
|
|
108
107
|
el('div', { class: 'settings-action-row__main flex gap-3', style: { alignItems: 'flex-start' } },
|
|
109
108
|
el('div', { class: 'empty__icon', style: { width: '30px', height: '30px', margin: 0, borderRadius: '8px' } }, icon('link')),
|
|
110
109
|
el('div', {},
|
|
111
|
-
el('div', { class: 'strong' }, '
|
|
110
|
+
el('div', { class: 'strong' }, t('settings.connection.mcpServer')),
|
|
112
111
|
el('div', { class: 'muted text-sm', style: { maxWidth: '440px', lineHeight: '1.6' } },
|
|
113
|
-
|
|
114
|
-
Btn('
|
|
112
|
+
t('settings.connection.mcpBody')))),
|
|
113
|
+
Btn(t('settings.connection.openInstall'), { icon: 'external', sm: true, variant: 'ghost', onClick: () => window.open(isDemo ? '/start.html' : '/install', '_blank') }));
|
|
115
114
|
|
|
116
115
|
return Card({
|
|
117
|
-
title: '
|
|
116
|
+
title: t('settings.connection.title'),
|
|
118
117
|
body: el('div', { class: 'stack-2' }, dashRow, el('div', { class: 'divider' }), mcpRow),
|
|
119
118
|
});
|
|
120
119
|
}
|
|
@@ -128,9 +127,9 @@ function currentTheme() {
|
|
|
128
127
|
|
|
129
128
|
function Theme() {
|
|
130
129
|
const options = [
|
|
131
|
-
{ value: 'light',
|
|
132
|
-
{ value: 'dark',
|
|
133
|
-
{ value: 'system',
|
|
130
|
+
{ value: 'light', key: 'settings.theme.light' },
|
|
131
|
+
{ value: 'dark', key: 'settings.theme.dark' },
|
|
132
|
+
{ value: 'system', key: 'settings.theme.system' },
|
|
134
133
|
];
|
|
135
134
|
|
|
136
135
|
const row = el('div', { class: 'flex gap-2 wrap' });
|
|
@@ -151,7 +150,7 @@ function Theme() {
|
|
|
151
150
|
function paint() {
|
|
152
151
|
const active = currentTheme();
|
|
153
152
|
mount(row, ...options.map((o) => {
|
|
154
|
-
const b = Btn(o.
|
|
153
|
+
const b = Btn(t(o.key), { variant: 'ghost', onClick: () => applyTheme(o.value) });
|
|
155
154
|
b.setAttribute('aria-pressed', String(o.value === active));
|
|
156
155
|
if (o.value === active) b.classList.add('is-selected');
|
|
157
156
|
return b;
|
|
@@ -160,8 +159,37 @@ function Theme() {
|
|
|
160
159
|
paint();
|
|
161
160
|
|
|
162
161
|
return Card({
|
|
163
|
-
title: '
|
|
164
|
-
sub: '
|
|
162
|
+
title: t('settings.theme.title'),
|
|
163
|
+
sub: t('settings.theme.sub'),
|
|
164
|
+
body: row,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ------------------------------------------------------------- 언어 / Language */
|
|
169
|
+
function LanguageSwitcher() {
|
|
170
|
+
// 테마 컴포넌트와 동일 패턴: localStorage 영속 + paint() + 변경 시 재페인트.
|
|
171
|
+
// 카탈로그가 채워지는 Phase 2부터 페이지 텍스트가 실제로 전환된다.
|
|
172
|
+
const options = [
|
|
173
|
+
{ value: 'ko', label: '한국어' },
|
|
174
|
+
{ value: 'en', label: 'English' },
|
|
175
|
+
];
|
|
176
|
+
const row = el('div', { class: 'flex gap-2 wrap' });
|
|
177
|
+
|
|
178
|
+
function paint() {
|
|
179
|
+
const active = getLang();
|
|
180
|
+
mount(row, ...options.map((o) => {
|
|
181
|
+
const b = Btn(o.label, { variant: 'ghost', onClick: () => setLang(o.value) });
|
|
182
|
+
b.setAttribute('aria-pressed', String(o.value === active));
|
|
183
|
+
if (o.value === active) b.classList.add('is-selected');
|
|
184
|
+
return b;
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
paint();
|
|
188
|
+
onLangChange(paint);
|
|
189
|
+
|
|
190
|
+
return Card({
|
|
191
|
+
title: '언어 / Language',
|
|
192
|
+
sub: '대시보드 표시 언어',
|
|
165
193
|
body: row,
|
|
166
194
|
});
|
|
167
195
|
}
|
|
@@ -169,8 +197,8 @@ function Theme() {
|
|
|
169
197
|
/* ------------------------------------------------- 자소서 수치 점검 모드 */
|
|
170
198
|
function VerifyMode(info) {
|
|
171
199
|
const options = [
|
|
172
|
-
{ value: false,
|
|
173
|
-
{ value: true,
|
|
200
|
+
{ value: false, labelKey: 'settings.verify.basic', descKey: 'settings.verify.basicDesc' },
|
|
201
|
+
{ value: true, labelKey: 'settings.verify.strict', descKey: 'settings.verify.strictDesc' },
|
|
174
202
|
];
|
|
175
203
|
let current = !!info.verify_strict;
|
|
176
204
|
const row = el('div', { class: 'flex gap-2 wrap' });
|
|
@@ -180,7 +208,7 @@ function VerifyMode(info) {
|
|
|
180
208
|
try {
|
|
181
209
|
const res = await post('/api/settings/verify-mode', { strict: value });
|
|
182
210
|
current = !!res.verify_strict;
|
|
183
|
-
toastOk(current ? '
|
|
211
|
+
toastOk(current ? t('settings.verify.strictOnToast') : t('settings.verify.strictOffToast'));
|
|
184
212
|
paint();
|
|
185
213
|
} catch (e) {
|
|
186
214
|
toastError(e);
|
|
@@ -189,7 +217,7 @@ function VerifyMode(info) {
|
|
|
189
217
|
|
|
190
218
|
function paint() {
|
|
191
219
|
mount(row, ...options.map((o) => {
|
|
192
|
-
const b = Btn(o.
|
|
220
|
+
const b = Btn(t(o.labelKey), { variant: 'ghost', onClick: () => set(o.value) });
|
|
193
221
|
b.setAttribute('aria-pressed', String(o.value === current));
|
|
194
222
|
if (o.value === current) b.classList.add('is-selected');
|
|
195
223
|
return b;
|
|
@@ -198,27 +226,27 @@ function VerifyMode(info) {
|
|
|
198
226
|
paint();
|
|
199
227
|
|
|
200
228
|
const note = el('div', { class: 'muted text-sm', style: { lineHeight: '1.7', marginTop: '10px' } },
|
|
201
|
-
el('div', {}, el('span', { class: 'strong' }, '
|
|
202
|
-
el('div', {}, el('span', { class: 'strong' }, '
|
|
229
|
+
el('div', {}, el('span', { class: 'strong' }, t('settings.verify.basic')), ' — ', t('settings.verify.basicDesc')),
|
|
230
|
+
el('div', {}, el('span', { class: 'strong' }, t('settings.verify.strict')), ' — ', t('settings.verify.strictDesc')),
|
|
203
231
|
el('div', { style: { marginTop: '6px' } },
|
|
204
|
-
'
|
|
232
|
+
t('settings.verify.note')));
|
|
205
233
|
|
|
206
234
|
return Card({
|
|
207
|
-
title: '
|
|
208
|
-
sub: '
|
|
235
|
+
title: t('settings.verify.title'),
|
|
236
|
+
sub: t('settings.verify.sub'),
|
|
209
237
|
body: el('div', { class: 'stack-2' }, row, note),
|
|
210
238
|
});
|
|
211
239
|
}
|
|
212
240
|
|
|
213
241
|
/* ---------------------------------------------------------- 내 데이터 */
|
|
214
242
|
function MyData(counts = {}) {
|
|
215
|
-
const items =
|
|
243
|
+
const items = COUNT_KEYS.map((key) =>
|
|
216
244
|
el('div', { class: 'countstrip__item' },
|
|
217
245
|
el('div', { class: 'countstrip__num tnum' }, String(counts[key] ?? 0)),
|
|
218
|
-
el('div', { class: 'countstrip__label' },
|
|
246
|
+
el('div', { class: 'countstrip__label' }, countLabel(key))));
|
|
219
247
|
return Card({
|
|
220
|
-
title: '
|
|
221
|
-
sub: '
|
|
248
|
+
title: t('settings.myData.title'),
|
|
249
|
+
sub: t('settings.myData.sub'),
|
|
222
250
|
body: el('div', { class: 'countstrip' }, ...items),
|
|
223
251
|
});
|
|
224
252
|
}
|
|
@@ -246,7 +274,7 @@ function Backup(backups = [], rerender) {
|
|
|
246
274
|
async function createBackup() {
|
|
247
275
|
try {
|
|
248
276
|
const res = await post('/api/settings/backup');
|
|
249
|
-
toastOk('
|
|
277
|
+
toastOk(t('settings.backup.createToast'));
|
|
250
278
|
await rerender();
|
|
251
279
|
} catch (e) {
|
|
252
280
|
toastError(e);
|
|
@@ -256,19 +284,19 @@ function Backup(backups = [], rerender) {
|
|
|
256
284
|
const list = backups.length
|
|
257
285
|
? el('table', { class: 'table' },
|
|
258
286
|
el('thead', {}, el('tr', {},
|
|
259
|
-
el('th', {}, '
|
|
287
|
+
el('th', {}, t('settings.backup.colFile')), el('th', {}, t('settings.backup.colCreated')), el('th', { style: { textAlign: 'right' } }, t('settings.backup.colSize')))),
|
|
260
288
|
el('tbody', {}, ...backups.map((b) => el('tr', {},
|
|
261
289
|
el('td', {}, el('span', { class: 'truncate', title: b.path || b.filename }, b.filename)),
|
|
262
290
|
el('td', { class: 'muted text-sm' }, fmtRelative(b.created_at)),
|
|
263
|
-
el('td', { class: 'muted text-sm tnum', style: { textAlign: 'right' } },
|
|
264
|
-
: el('p', { class: 'muted', style: { margin: 0 } }, '
|
|
291
|
+
el('td', { class: 'muted text-sm tnum', style: { textAlign: 'right' } }, t('settings.backup.size', { kb: Math.round((b.size || 0) / 1024) }))))))
|
|
292
|
+
: el('p', { class: 'muted', style: { margin: 0 } }, t('settings.backup.empty'));
|
|
265
293
|
|
|
266
294
|
return Card({
|
|
267
|
-
title: '
|
|
295
|
+
title: t('settings.backup.title'),
|
|
268
296
|
actions: [
|
|
269
|
-
Btn('
|
|
270
|
-
Btn('
|
|
271
|
-
Btn('
|
|
297
|
+
Btn(t('settings.backup.export'), { icon: 'download', sm: true, variant: 'primary', onClick: () => downloadUrl('/api/settings/export-all') }),
|
|
298
|
+
Btn(t('settings.backup.import'), { icon: 'upload', sm: true, variant: 'ghost', onClick: () => fileInput.click() }),
|
|
299
|
+
Btn(t('settings.backup.create'), { icon: 'copy', sm: true, variant: 'ghost', onClick: createBackup }),
|
|
272
300
|
],
|
|
273
301
|
body: el('div', { class: 'stack-3' }, fileInput, list),
|
|
274
302
|
});
|
|
@@ -277,12 +305,12 @@ function Backup(backups = [], rerender) {
|
|
|
277
305
|
function readBackupFile(file) {
|
|
278
306
|
return new Promise((resolve, reject) => {
|
|
279
307
|
const reader = new FileReader();
|
|
280
|
-
reader.onerror = () => reject(new Error('
|
|
308
|
+
reader.onerror = () => reject(new Error(t('settings.backup.readError')));
|
|
281
309
|
reader.onload = () => {
|
|
282
310
|
try {
|
|
283
311
|
resolve(JSON.parse(String(reader.result || '')));
|
|
284
312
|
} catch {
|
|
285
|
-
reject(new Error('
|
|
313
|
+
reject(new Error(t('settings.backup.parseError')));
|
|
286
314
|
}
|
|
287
315
|
};
|
|
288
316
|
reader.readAsText(file);
|
|
@@ -290,66 +318,73 @@ function readBackupFile(file) {
|
|
|
290
318
|
}
|
|
291
319
|
|
|
292
320
|
function fmtBackupDate(iso) {
|
|
293
|
-
if (!iso) return '
|
|
321
|
+
if (!iso) return t('settings.backup.unknownDate');
|
|
294
322
|
const d = new Date(iso);
|
|
295
323
|
if (Number.isNaN(d.getTime())) return iso;
|
|
296
|
-
return
|
|
324
|
+
return new Intl.DateTimeFormat(getLang(), { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(d);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Resolve a backup-count data key to its human label (falls back to the raw key).
|
|
328
|
+
function backupCountLabel(key) {
|
|
329
|
+
const label = t('settings.count.' + key);
|
|
330
|
+
return label === 'settings.count.' + key ? key : label;
|
|
297
331
|
}
|
|
298
332
|
|
|
299
333
|
function previewRows(counts = {}) {
|
|
300
334
|
const entries = Object.entries(counts)
|
|
301
335
|
.filter(([, count]) => Number(count) > 0)
|
|
302
|
-
.sort(([a], [b]) => (
|
|
336
|
+
.sort(([a], [b]) => backupCountLabel(a).localeCompare(backupCountLabel(b), getLang()));
|
|
303
337
|
|
|
304
338
|
if (!entries.length) {
|
|
305
|
-
return el('p', { class: 'muted', style: { margin: 0 } }, '
|
|
339
|
+
return el('p', { class: 'muted', style: { margin: 0 } }, t('settings.preview.empty'));
|
|
306
340
|
}
|
|
307
341
|
|
|
308
342
|
return el('table', { class: 'table' },
|
|
309
|
-
el('thead', {}, el('tr', {}, el('th', {}, '
|
|
343
|
+
el('thead', {}, el('tr', {}, el('th', {}, t('settings.preview.colData')), el('th', { style: { textAlign: 'right' } }, t('settings.preview.colCount')))),
|
|
310
344
|
el('tbody', {}, ...entries.map(([key, count]) => el('tr', {},
|
|
311
|
-
el('td', {},
|
|
345
|
+
el('td', {}, backupCountLabel(key)),
|
|
312
346
|
el('td', { class: 'tnum', style: { textAlign: 'right' } }, String(count))))));
|
|
313
347
|
}
|
|
314
348
|
|
|
315
349
|
function openImportModal({ fileName, backup, preview, rerender }) {
|
|
316
350
|
let confirmBtn;
|
|
351
|
+
const gateWord = t('settings.import.gateWord');
|
|
317
352
|
const input = Input({
|
|
318
|
-
placeholder:
|
|
319
|
-
onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !==
|
|
353
|
+
placeholder: gateWord,
|
|
354
|
+
onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !== gateWord; },
|
|
320
355
|
onKeydown: (e) => {
|
|
321
|
-
if (e.key === 'Enter' && e.target.value.trim() ===
|
|
356
|
+
if (e.key === 'Enter' && e.target.value.trim() === gateWord && confirmBtn && !confirmBtn.disabled) confirmBtn.click();
|
|
322
357
|
},
|
|
323
358
|
});
|
|
324
359
|
|
|
325
360
|
openModal({
|
|
326
|
-
title: '
|
|
361
|
+
title: t('settings.import.title'),
|
|
327
362
|
size: 'lg',
|
|
328
363
|
body: el('div', { class: 'stack-3' },
|
|
329
364
|
el('div', { class: 'callout' },
|
|
330
365
|
icon('info'),
|
|
331
366
|
el('div', {},
|
|
332
|
-
el('div', { class: 'callout__title' }, '
|
|
367
|
+
el('div', { class: 'callout__title' }, t('settings.import.calloutTitle')),
|
|
333
368
|
el('div', { class: 'callout__body' },
|
|
334
|
-
'
|
|
369
|
+
t('settings.import.calloutBody')))),
|
|
335
370
|
el('dl', { class: 'kv' },
|
|
336
|
-
...kvRow('
|
|
337
|
-
...kvRow('
|
|
338
|
-
...kvRow('
|
|
339
|
-
...kvRow('
|
|
371
|
+
...kvRow(t('settings.import.file'), fileName, false),
|
|
372
|
+
...kvRow(t('settings.import.exportedAt'), fmtBackupDate(preview.exported_at), false),
|
|
373
|
+
...kvRow(t('settings.import.schema'), t('settings.import.schemaValue', { version: preview.version, current: preview.current_version }), false),
|
|
374
|
+
...kvRow(t('settings.import.totalRows'), String(preview.total_rows || 0), false)),
|
|
340
375
|
previewRows(preview.counts),
|
|
341
376
|
preview.warnings?.length
|
|
342
377
|
? el('div', { class: 'callout' },
|
|
343
378
|
icon('info'),
|
|
344
379
|
el('div', {},
|
|
345
|
-
el('div', { class: 'callout__title' }, '
|
|
380
|
+
el('div', { class: 'callout__title' }, t('settings.import.warningsTitle')),
|
|
346
381
|
el('div', { class: 'callout__body' }, preview.warnings.join(' '))))
|
|
347
382
|
: null,
|
|
348
383
|
el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } },
|
|
349
|
-
|
|
384
|
+
t('settings.import.instruction', { word: gateWord })),
|
|
350
385
|
input),
|
|
351
386
|
footer: (close) => {
|
|
352
|
-
confirmBtn = Btn('
|
|
387
|
+
confirmBtn = Btn(t('settings.import.confirm'), {
|
|
353
388
|
icon: 'upload',
|
|
354
389
|
variant: 'danger',
|
|
355
390
|
disabled: true,
|
|
@@ -358,45 +393,62 @@ function openImportModal({ fileName, backup, preview, rerender }) {
|
|
|
358
393
|
try {
|
|
359
394
|
await post('/api/settings/restore', { backup, confirm: 'RESTORE' });
|
|
360
395
|
close();
|
|
361
|
-
toastOk('
|
|
396
|
+
toastOk(t('settings.import.doneToast'));
|
|
362
397
|
await rerender();
|
|
363
398
|
} catch (e) {
|
|
364
399
|
toastError(e);
|
|
365
|
-
confirmBtn.disabled = input.value.trim() !==
|
|
400
|
+
confirmBtn.disabled = input.value.trim() !== gateWord;
|
|
366
401
|
}
|
|
367
402
|
},
|
|
368
403
|
});
|
|
369
|
-
return [Btn('
|
|
404
|
+
return [Btn(t('settings.cancel'), { onClick: close }), confirmBtn];
|
|
370
405
|
},
|
|
371
406
|
});
|
|
372
407
|
}
|
|
373
408
|
|
|
374
409
|
/* -------------------------------------------------- 위험 구역 (Danger zone) */
|
|
410
|
+
/* ----------------------------------------------------- 도움말 · 문제 신고 */
|
|
411
|
+
function Support() {
|
|
412
|
+
const ext = (href, iconName, label) =>
|
|
413
|
+
el('a', { class: 'btn btn--ghost btn--sm', href, attrs: { target: '_blank', rel: 'noopener noreferrer' } },
|
|
414
|
+
icon(iconName), el('span', {}, label));
|
|
415
|
+
return Card({
|
|
416
|
+
title: t('settings.support.title'),
|
|
417
|
+
sub: t('settings.support.sub'),
|
|
418
|
+
body: el('div', { class: 'flex gap-2 wrap' },
|
|
419
|
+
ext('https://github.com/osntak/careermate/issues/new?template=bug_report.yml', 'external', t('settings.support.report')),
|
|
420
|
+
ext('https://github.com/osntak/careermate/discussions', 'external', t('settings.support.ask')),
|
|
421
|
+
el('a', { class: 'btn btn--ghost btn--sm', href: 'mailto:osntak@gmail.com' }, icon('mail'), el('span', {}, t('settings.support.email'))),
|
|
422
|
+
),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
375
426
|
function DangerZone(ctx) {
|
|
376
427
|
function openResetModal() {
|
|
377
428
|
let confirmBtn;
|
|
378
|
-
// Gate on the
|
|
429
|
+
// Gate on the locale-resolved word so there's no IME switch and it matches the button.
|
|
379
430
|
// (The API still expects the fixed 'DELETE' token; we send that on the wire below.)
|
|
431
|
+
const gateWord = t('settings.reset.gateWord');
|
|
380
432
|
const input = Input({
|
|
381
|
-
placeholder:
|
|
382
|
-
onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !==
|
|
383
|
-
onKeydown: (e) => { if (e.key === 'Enter' && e.target.value.trim() ===
|
|
433
|
+
placeholder: gateWord,
|
|
434
|
+
onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !== gateWord; },
|
|
435
|
+
onKeydown: (e) => { if (e.key === 'Enter' && e.target.value.trim() === gateWord && confirmBtn && !confirmBtn.disabled) confirmBtn.click(); },
|
|
384
436
|
});
|
|
385
437
|
|
|
386
438
|
openModal({
|
|
387
|
-
title: '
|
|
439
|
+
title: t('settings.reset.title'),
|
|
388
440
|
body: el('div', { class: 'stack-3' },
|
|
389
441
|
el('div', { class: 'callout' },
|
|
390
442
|
icon('info'),
|
|
391
443
|
el('div', {},
|
|
392
|
-
el('div', { class: 'callout__title' }, '
|
|
444
|
+
el('div', { class: 'callout__title' }, t('settings.reset.calloutTitle')),
|
|
393
445
|
el('div', { class: 'callout__body' },
|
|
394
|
-
'
|
|
446
|
+
t('settings.reset.calloutBody')))),
|
|
395
447
|
el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } },
|
|
396
|
-
|
|
448
|
+
t('settings.reset.instruction', { word: gateWord })),
|
|
397
449
|
input),
|
|
398
450
|
footer: (close) => {
|
|
399
|
-
confirmBtn = Btn('
|
|
451
|
+
confirmBtn = Btn(t('settings.reset.confirm'), {
|
|
400
452
|
variant: 'danger',
|
|
401
453
|
disabled: true,
|
|
402
454
|
onClick: async () => {
|
|
@@ -404,28 +456,28 @@ function DangerZone(ctx) {
|
|
|
404
456
|
try {
|
|
405
457
|
await post('/api/settings/reset', { confirm: 'DELETE' });
|
|
406
458
|
close();
|
|
407
|
-
toastOk('
|
|
459
|
+
toastOk(t('settings.reset.doneToast'));
|
|
408
460
|
navigate('/');
|
|
409
461
|
await ctx.refreshNav();
|
|
410
462
|
} catch (e) {
|
|
411
463
|
toastError(e);
|
|
412
|
-
confirmBtn.disabled = input.value.trim() !==
|
|
464
|
+
confirmBtn.disabled = input.value.trim() !== gateWord;
|
|
413
465
|
}
|
|
414
466
|
},
|
|
415
467
|
});
|
|
416
|
-
return [Btn('
|
|
468
|
+
return [Btn(t('settings.cancel'), { onClick: close }), confirmBtn];
|
|
417
469
|
},
|
|
418
470
|
});
|
|
419
471
|
}
|
|
420
472
|
|
|
421
473
|
return Card({
|
|
422
|
-
title: '
|
|
423
|
-
sub: '
|
|
474
|
+
title: t('settings.danger.title'),
|
|
475
|
+
sub: t('settings.danger.sub'),
|
|
424
476
|
body: el('div', { class: 'flex between gap-3 wrap' },
|
|
425
477
|
el('div', { style: { maxWidth: '440px' } },
|
|
426
|
-
el('div', { class: 'strong' }, '
|
|
478
|
+
el('div', { class: 'strong' }, t('settings.danger.resetTitle')),
|
|
427
479
|
el('div', { class: 'muted text-sm', style: { lineHeight: '1.6' } },
|
|
428
|
-
'
|
|
429
|
-
Btn('
|
|
480
|
+
t('settings.danger.resetBody'))),
|
|
481
|
+
Btn(t('settings.danger.resetTitle'), { icon: 'trash', variant: 'danger', onClick: openResetModal })),
|
|
430
482
|
});
|
|
431
483
|
}
|
package/dist/public/styles.css
CHANGED
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
--maxw: 1080px;
|
|
62
62
|
|
|
63
63
|
--font: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont,
|
|
64
|
-
'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
|
|
64
|
+
'Segoe UI', system-ui, 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
|
|
65
65
|
--mono: 'SFMono-Regular', 'JetBrains Mono', Consolas, 'D2Coding', monospace;
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -317,6 +317,33 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
317
317
|
.tl-item__line { width: 2px; flex: 1; background: var(--border); margin: 4px 0; }
|
|
318
318
|
.tl-item:last-child .tl-item__line { display: none; }
|
|
319
319
|
.tl-item__body { padding-bottom: 16px; }
|
|
320
|
+
.timeline--job .tl-item__dot { margin-top: 7px; }
|
|
321
|
+
.timeline-event__head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; min-width: 0; }
|
|
322
|
+
.timeline-event__title { font-weight: 650; font-size: 14px; color: var(--text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
323
|
+
.timeline-event__summary { margin-top: 3px; color: var(--text-secondary); font-size: 13.5px; line-height: 1.5; }
|
|
324
|
+
.timeline-event__extra { display: flex; flex-direction: column; gap: 5px; margin-top: 8px; }
|
|
325
|
+
.timeline-event__meta { color: var(--text-tertiary); font-size: 13px; }
|
|
326
|
+
.timeline-docs { display: flex; flex-direction: column; gap: 4px; }
|
|
327
|
+
.timeline-ref { display: flex; align-items: center; gap: 7px; min-width: 0; font-size: 13.5px; }
|
|
328
|
+
.timeline-ref__label { flex: none; color: var(--text-tertiary); }
|
|
329
|
+
.timeline-ref__link {
|
|
330
|
+
appearance: none; border: 0; background: transparent; color: var(--text); padding: 1px 4px;
|
|
331
|
+
border-radius: 4px; font: inherit; font-weight: 600; cursor: pointer; min-width: 0;
|
|
332
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
333
|
+
}
|
|
334
|
+
.timeline-ref__link:hover { background: var(--surface-hover); text-decoration: underline; }
|
|
335
|
+
.timeline-ref__missing { color: var(--text-tertiary); font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
336
|
+
.timeline-ref__deleted { flex: none; padding: 1px 6px; border-radius: 999px; background: var(--bg-sunken); color: var(--text-tertiary); font-size: 11.5px; font-weight: 650; }
|
|
337
|
+
.pick-list { display: flex; flex-direction: column; gap: 6px; }
|
|
338
|
+
.pick-row {
|
|
339
|
+
display: flex; align-items: flex-start; gap: 10px; padding: 9px 10px; border: 1px solid var(--border);
|
|
340
|
+
border-radius: var(--radius-sm); cursor: pointer; background: var(--surface);
|
|
341
|
+
}
|
|
342
|
+
.pick-row:hover { background: var(--surface-hover); border-color: var(--border-strong); }
|
|
343
|
+
.pick-row input { margin-top: 3px; flex: none; }
|
|
344
|
+
.pick-row__main { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
|
345
|
+
.pick-row__title { font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
346
|
+
.pick-row__sub { color: var(--text-tertiary); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
320
347
|
|
|
321
348
|
/* ----------------------------------------------------------------- kanban */
|
|
322
349
|
.board { display: grid; grid-auto-flow: column; grid-auto-columns: 272px; gap: 14px; overflow-x: auto; padding-bottom: 8px; }
|