careermate 0.0.4 → 0.0.6
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 +11 -7
- package/INSTALL.md +18 -16
- package/README.md +28 -26
- package/THIRD_PARTY_NOTICES.md +2 -3
- package/bin/careermate.mjs +8 -1
- package/dist/career-os/README.md +8 -3
- package/dist/career-os/eop/career-description.md +136 -0
- package/dist/career-os/eop/cover-letter.md +7 -5
- package/dist/career-os/eop/fit-analysis.md +15 -5
- package/dist/career-os/eop/interview-prep.md +209 -21
- package/dist/career-os/eop/job-analysis.md +5 -3
- package/dist/career-os/eop/profile-extraction.md +5 -4
- package/dist/career-os/knowledge/cover-letter.md +4 -4
- package/dist/career-os/knowledge/fit-matching.md +8 -8
- 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 +2 -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 +1019 -127
- package/dist/mcp.mjs +1036 -126
- 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 +25 -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 +270 -72
- package/dist/public/styles.css +38 -3
- package/dist/web.mjs +859 -103
- package/docs/FAQ.md +26 -5
- package/docs/MCP_TOOLS.md +26 -12
- package/docs/START_WORKFLOW.md +2 -2
- package/docs/SUPPORTED_AI_APPS.md +13 -0
- package/docs/WORKFLOWS.md +80 -23
- package/docs/career-os/README.md +8 -3
- package/docs/career-os/eop/career-description.md +136 -0
- package/docs/career-os/eop/cover-letter.md +7 -5
- package/docs/career-os/eop/fit-analysis.md +15 -5
- package/docs/career-os/eop/interview-prep.md +209 -21
- package/docs/career-os/eop/job-analysis.md +5 -3
- package/docs/career-os/eop/profile-extraction.md +5 -4
- package/docs/career-os/knowledge/cover-letter.md +4 -4
- package/docs/career-os/knowledge/fit-matching.md +8 -8
- 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 +2 -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 +138 -21
- 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 +46 -7
- package/packages/mcp-tools/src/bridge.ts +194 -13
- package/packages/mcp-tools/src/tools.ts +390 -38
- package/packages/mcp-tools/src/update.ts +31 -16
- 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 +17 -8
- package/packages/shared/src/enums.ts +14 -4
- package/packages/shared/src/schemas.ts +41 -0
- package/packages/workflows/src/definitions.ts +47 -10
- 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 -565
- package/dist/site/landing.js +0 -54
- 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 -353
- package/dist/site/style.css +0 -559
- package/dist/site/vercel.json +0 -20
|
@@ -4,12 +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
|
-
};
|
|
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);
|
|
13
12
|
|
|
14
13
|
export async function render(ctx) {
|
|
15
14
|
ctx.setActions([]);
|
|
@@ -19,9 +18,11 @@ export async function render(ctx) {
|
|
|
19
18
|
DataLocation(info),
|
|
20
19
|
Connection(info),
|
|
21
20
|
Theme(),
|
|
21
|
+
LanguageSwitcher(),
|
|
22
22
|
VerifyMode(info),
|
|
23
23
|
MyData(info.counts),
|
|
24
|
-
Backup(backups, () => render(ctx)),
|
|
24
|
+
Backup(backups, async () => { await ctx.refreshNav(); await render(ctx); }),
|
|
25
|
+
Support(),
|
|
25
26
|
DangerZone(ctx),
|
|
26
27
|
);
|
|
27
28
|
|
|
@@ -36,55 +37,83 @@ function kvRow(label, value, copyable) {
|
|
|
36
37
|
el('div', { class: 'flex between gap-3' },
|
|
37
38
|
el('span', { class: 'truncate tnum', style: { fontFamily: 'var(--mono, ui-monospace, monospace)' }, title: value }, value || '—'),
|
|
38
39
|
copyable && value
|
|
39
|
-
? IconBtn('copy', { title: '
|
|
40
|
+
? IconBtn('copy', { title: t('settings.copy'), onClick: () => copyText(value) })
|
|
40
41
|
: null)),
|
|
41
42
|
];
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
function DataLocation(info) {
|
|
45
46
|
const kv = el('dl', { class: 'kv' },
|
|
46
|
-
...kvRow('
|
|
47
|
-
...kvRow('
|
|
48
|
-
...kvRow('
|
|
49
|
-
...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),
|
|
50
51
|
);
|
|
51
52
|
|
|
52
53
|
const privacy = el('div', { class: 'callout callout--privacy' },
|
|
53
54
|
icon('lock'),
|
|
54
55
|
el('div', {},
|
|
55
|
-
el('div', { class: 'callout__title' }, '
|
|
56
|
+
el('div', { class: 'callout__title' }, t('settings.dataLocation.privacyTitle')),
|
|
56
57
|
el('div', { class: 'callout__body' },
|
|
57
|
-
'
|
|
58
|
+
t('settings.dataLocation.privacyBody'))));
|
|
58
59
|
|
|
59
60
|
return Card({
|
|
60
|
-
title: '
|
|
61
|
+
title: t('settings.dataLocation.title'),
|
|
61
62
|
body: el('div', { class: 'stack-3' }, kv, privacy),
|
|
62
63
|
});
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
/* ---------------------------------------------------------- 연결 상태 */
|
|
66
67
|
function Connection(info) {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
const isDemo = !!info.demo;
|
|
69
|
+
|
|
70
|
+
async function createShortcut() {
|
|
71
|
+
try {
|
|
72
|
+
const { shortcut } = await post('/api/settings/dashboard-shortcut', { open: true });
|
|
73
|
+
toastOk(isDemo ? t('settings.connection.shortcutDemoToast') : t('settings.connection.shortcutToast'));
|
|
74
|
+
openModal({
|
|
75
|
+
title: isDemo ? t('settings.connection.shortcutDemoTitle') : t('settings.connection.shortcutTitle'),
|
|
76
|
+
body: el('div', { class: 'stack-3' },
|
|
77
|
+
el('div', { class: 'callout' },
|
|
78
|
+
icon('info'),
|
|
79
|
+
el('div', {},
|
|
80
|
+
el('div', { class: 'callout__title' }, isDemo ? t('settings.connection.shortcutDemoCalloutTitle') : t('settings.connection.shortcutCalloutTitle')),
|
|
81
|
+
el('div', { class: 'callout__body' },
|
|
82
|
+
isDemo
|
|
83
|
+
? t('settings.connection.shortcutDemoCalloutBody')
|
|
84
|
+
: t('settings.connection.shortcutCalloutBody')))),
|
|
85
|
+
el('dl', { class: 'kv' },
|
|
86
|
+
...kvRow(t('settings.connection.shortcutFolder'), shortcut.shortcut_dir, true),
|
|
87
|
+
...kvRow(t('settings.connection.shortcutLauncher'), shortcut.launcher_path, true))),
|
|
88
|
+
footer: (close) => [
|
|
89
|
+
Btn(t('settings.copyPath'), { icon: 'copy', onClick: () => copyText(shortcut.launcher_path) }),
|
|
90
|
+
Btn(t('settings.confirm'), { variant: 'primary', onClick: close }),
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
} catch (e) {
|
|
94
|
+
toastError(e);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const dashRow = el('div', { class: 'settings-action-row flex between gap-3', style: { padding: '4px 0' } },
|
|
99
|
+
el('div', { class: 'settings-action-row__main flex gap-3', style: { alignItems: 'center' } },
|
|
100
|
+
el('span', { class: 'badge badge--final_passed' }, el('span', { class: 'dot' }), t('settings.connection.running')),
|
|
70
101
|
el('div', {},
|
|
71
|
-
el('div', { class: 'strong' }, '
|
|
102
|
+
el('div', { class: 'strong' }, t('settings.connection.dashboard')),
|
|
72
103
|
el('div', { class: 'muted text-sm tnum' }, info.url || '—'))),
|
|
73
|
-
|
|
74
|
-
? Btn('새 탭에서 열기', { icon: 'external', sm: true, variant: 'ghost', onClick: () => window.open(info.url, '_blank') })
|
|
75
|
-
: null);
|
|
104
|
+
Btn(t('settings.connection.createShortcut'), { icon: 'copy', sm: true, variant: 'ghost', onClick: createShortcut }));
|
|
76
105
|
|
|
77
|
-
const mcpRow = el('div', { class: 'flex between gap-3', style: { padding: '4px 0', alignItems: 'flex-start' } },
|
|
78
|
-
el('div', { class: 'flex gap-3', style: { alignItems: 'flex-start' } },
|
|
106
|
+
const mcpRow = el('div', { class: 'settings-action-row flex between gap-3', style: { padding: '4px 0', alignItems: 'flex-start' } },
|
|
107
|
+
el('div', { class: 'settings-action-row__main flex gap-3', style: { alignItems: 'flex-start' } },
|
|
79
108
|
el('div', { class: 'empty__icon', style: { width: '30px', height: '30px', margin: 0, borderRadius: '8px' } }, icon('link')),
|
|
80
109
|
el('div', {},
|
|
81
|
-
el('div', { class: 'strong' }, '
|
|
110
|
+
el('div', { class: 'strong' }, t('settings.connection.mcpServer')),
|
|
82
111
|
el('div', { class: 'muted text-sm', style: { maxWidth: '440px', lineHeight: '1.6' } },
|
|
83
|
-
|
|
84
|
-
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') }));
|
|
85
114
|
|
|
86
115
|
return Card({
|
|
87
|
-
title: '
|
|
116
|
+
title: t('settings.connection.title'),
|
|
88
117
|
body: el('div', { class: 'stack-2' }, dashRow, el('div', { class: 'divider' }), mcpRow),
|
|
89
118
|
});
|
|
90
119
|
}
|
|
@@ -98,9 +127,9 @@ function currentTheme() {
|
|
|
98
127
|
|
|
99
128
|
function Theme() {
|
|
100
129
|
const options = [
|
|
101
|
-
{ value: 'light',
|
|
102
|
-
{ value: 'dark',
|
|
103
|
-
{ value: 'system',
|
|
130
|
+
{ value: 'light', key: 'settings.theme.light' },
|
|
131
|
+
{ value: 'dark', key: 'settings.theme.dark' },
|
|
132
|
+
{ value: 'system', key: 'settings.theme.system' },
|
|
104
133
|
];
|
|
105
134
|
|
|
106
135
|
const row = el('div', { class: 'flex gap-2 wrap' });
|
|
@@ -121,17 +150,46 @@ function Theme() {
|
|
|
121
150
|
function paint() {
|
|
122
151
|
const active = currentTheme();
|
|
123
152
|
mount(row, ...options.map((o) => {
|
|
124
|
-
const b = Btn(o.
|
|
153
|
+
const b = Btn(t(o.key), { variant: 'ghost', onClick: () => applyTheme(o.value) });
|
|
154
|
+
b.setAttribute('aria-pressed', String(o.value === active));
|
|
155
|
+
if (o.value === active) b.classList.add('is-selected');
|
|
156
|
+
return b;
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
paint();
|
|
160
|
+
|
|
161
|
+
return Card({
|
|
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) });
|
|
125
182
|
b.setAttribute('aria-pressed', String(o.value === active));
|
|
126
183
|
if (o.value === active) b.classList.add('is-selected');
|
|
127
184
|
return b;
|
|
128
185
|
}));
|
|
129
186
|
}
|
|
130
187
|
paint();
|
|
188
|
+
onLangChange(paint);
|
|
131
189
|
|
|
132
190
|
return Card({
|
|
133
|
-
title: '
|
|
134
|
-
sub: '
|
|
191
|
+
title: '언어 / Language',
|
|
192
|
+
sub: '대시보드 표시 언어',
|
|
135
193
|
body: row,
|
|
136
194
|
});
|
|
137
195
|
}
|
|
@@ -139,8 +197,8 @@ function Theme() {
|
|
|
139
197
|
/* ------------------------------------------------- 자소서 수치 점검 모드 */
|
|
140
198
|
function VerifyMode(info) {
|
|
141
199
|
const options = [
|
|
142
|
-
{ value: false,
|
|
143
|
-
{ value: true,
|
|
200
|
+
{ value: false, labelKey: 'settings.verify.basic', descKey: 'settings.verify.basicDesc' },
|
|
201
|
+
{ value: true, labelKey: 'settings.verify.strict', descKey: 'settings.verify.strictDesc' },
|
|
144
202
|
];
|
|
145
203
|
let current = !!info.verify_strict;
|
|
146
204
|
const row = el('div', { class: 'flex gap-2 wrap' });
|
|
@@ -150,7 +208,7 @@ function VerifyMode(info) {
|
|
|
150
208
|
try {
|
|
151
209
|
const res = await post('/api/settings/verify-mode', { strict: value });
|
|
152
210
|
current = !!res.verify_strict;
|
|
153
|
-
toastOk(current ? '
|
|
211
|
+
toastOk(current ? t('settings.verify.strictOnToast') : t('settings.verify.strictOffToast'));
|
|
154
212
|
paint();
|
|
155
213
|
} catch (e) {
|
|
156
214
|
toastError(e);
|
|
@@ -159,7 +217,7 @@ function VerifyMode(info) {
|
|
|
159
217
|
|
|
160
218
|
function paint() {
|
|
161
219
|
mount(row, ...options.map((o) => {
|
|
162
|
-
const b = Btn(o.
|
|
220
|
+
const b = Btn(t(o.labelKey), { variant: 'ghost', onClick: () => set(o.value) });
|
|
163
221
|
b.setAttribute('aria-pressed', String(o.value === current));
|
|
164
222
|
if (o.value === current) b.classList.add('is-selected');
|
|
165
223
|
return b;
|
|
@@ -168,37 +226,55 @@ function VerifyMode(info) {
|
|
|
168
226
|
paint();
|
|
169
227
|
|
|
170
228
|
const note = el('div', { class: 'muted text-sm', style: { lineHeight: '1.7', marginTop: '10px' } },
|
|
171
|
-
el('div', {}, el('span', { class: 'strong' }, '
|
|
172
|
-
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')),
|
|
173
231
|
el('div', { style: { marginTop: '6px' } },
|
|
174
|
-
'
|
|
232
|
+
t('settings.verify.note')));
|
|
175
233
|
|
|
176
234
|
return Card({
|
|
177
|
-
title: '
|
|
178
|
-
sub: '
|
|
235
|
+
title: t('settings.verify.title'),
|
|
236
|
+
sub: t('settings.verify.sub'),
|
|
179
237
|
body: el('div', { class: 'stack-2' }, row, note),
|
|
180
238
|
});
|
|
181
239
|
}
|
|
182
240
|
|
|
183
241
|
/* ---------------------------------------------------------- 내 데이터 */
|
|
184
242
|
function MyData(counts = {}) {
|
|
185
|
-
const items =
|
|
243
|
+
const items = COUNT_KEYS.map((key) =>
|
|
186
244
|
el('div', { class: 'countstrip__item' },
|
|
187
245
|
el('div', { class: 'countstrip__num tnum' }, String(counts[key] ?? 0)),
|
|
188
|
-
el('div', { class: 'countstrip__label' },
|
|
246
|
+
el('div', { class: 'countstrip__label' }, countLabel(key))));
|
|
189
247
|
return Card({
|
|
190
|
-
title: '
|
|
191
|
-
sub: '
|
|
248
|
+
title: t('settings.myData.title'),
|
|
249
|
+
sub: t('settings.myData.sub'),
|
|
192
250
|
body: el('div', { class: 'countstrip' }, ...items),
|
|
193
251
|
});
|
|
194
252
|
}
|
|
195
253
|
|
|
196
|
-
/* -------------------------------------------------
|
|
254
|
+
/* ------------------------------------------------- 내보내기 / 가져오기 */
|
|
197
255
|
function Backup(backups = [], rerender) {
|
|
256
|
+
const fileInput = el('input', {
|
|
257
|
+
type: 'file',
|
|
258
|
+
accept: '.json,application/json',
|
|
259
|
+
style: { display: 'none' },
|
|
260
|
+
onChange: async (e) => {
|
|
261
|
+
const file = e.target.files?.[0];
|
|
262
|
+
e.target.value = '';
|
|
263
|
+
if (!file) return;
|
|
264
|
+
try {
|
|
265
|
+
const backup = await readBackupFile(file);
|
|
266
|
+
const { preview } = await post('/api/settings/import-preview', { backup });
|
|
267
|
+
openImportModal({ fileName: file.name, backup, preview, rerender });
|
|
268
|
+
} catch (err) {
|
|
269
|
+
toastError(err);
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
198
274
|
async function createBackup() {
|
|
199
275
|
try {
|
|
200
276
|
const res = await post('/api/settings/backup');
|
|
201
|
-
toastOk('
|
|
277
|
+
toastOk(t('settings.backup.createToast'));
|
|
202
278
|
await rerender();
|
|
203
279
|
} catch (e) {
|
|
204
280
|
toastError(e);
|
|
@@ -208,49 +284,171 @@ function Backup(backups = [], rerender) {
|
|
|
208
284
|
const list = backups.length
|
|
209
285
|
? el('table', { class: 'table' },
|
|
210
286
|
el('thead', {}, el('tr', {},
|
|
211
|
-
el('th', {}, '
|
|
287
|
+
el('th', {}, t('settings.backup.colFile')), el('th', {}, t('settings.backup.colCreated')), el('th', { style: { textAlign: 'right' } }, t('settings.backup.colSize')))),
|
|
212
288
|
el('tbody', {}, ...backups.map((b) => el('tr', {},
|
|
213
289
|
el('td', {}, el('span', { class: 'truncate', title: b.path || b.filename }, b.filename)),
|
|
214
290
|
el('td', { class: 'muted text-sm' }, fmtRelative(b.created_at)),
|
|
215
|
-
el('td', { class: 'muted text-sm tnum', style: { textAlign: 'right' } },
|
|
216
|
-
: 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'));
|
|
217
293
|
|
|
218
294
|
return Card({
|
|
219
|
-
title: '
|
|
295
|
+
title: t('settings.backup.title'),
|
|
220
296
|
actions: [
|
|
221
|
-
Btn('
|
|
222
|
-
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 }),
|
|
223
300
|
],
|
|
224
|
-
body: list,
|
|
301
|
+
body: el('div', { class: 'stack-3' }, fileInput, list),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function readBackupFile(file) {
|
|
306
|
+
return new Promise((resolve, reject) => {
|
|
307
|
+
const reader = new FileReader();
|
|
308
|
+
reader.onerror = () => reject(new Error(t('settings.backup.readError')));
|
|
309
|
+
reader.onload = () => {
|
|
310
|
+
try {
|
|
311
|
+
resolve(JSON.parse(String(reader.result || '')));
|
|
312
|
+
} catch {
|
|
313
|
+
reject(new Error(t('settings.backup.parseError')));
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
reader.readAsText(file);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function fmtBackupDate(iso) {
|
|
321
|
+
if (!iso) return t('settings.backup.unknownDate');
|
|
322
|
+
const d = new Date(iso);
|
|
323
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
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;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function previewRows(counts = {}) {
|
|
334
|
+
const entries = Object.entries(counts)
|
|
335
|
+
.filter(([, count]) => Number(count) > 0)
|
|
336
|
+
.sort(([a], [b]) => backupCountLabel(a).localeCompare(backupCountLabel(b), getLang()));
|
|
337
|
+
|
|
338
|
+
if (!entries.length) {
|
|
339
|
+
return el('p', { class: 'muted', style: { margin: 0 } }, t('settings.preview.empty'));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return el('table', { class: 'table' },
|
|
343
|
+
el('thead', {}, el('tr', {}, el('th', {}, t('settings.preview.colData')), el('th', { style: { textAlign: 'right' } }, t('settings.preview.colCount')))),
|
|
344
|
+
el('tbody', {}, ...entries.map(([key, count]) => el('tr', {},
|
|
345
|
+
el('td', {}, backupCountLabel(key)),
|
|
346
|
+
el('td', { class: 'tnum', style: { textAlign: 'right' } }, String(count))))));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function openImportModal({ fileName, backup, preview, rerender }) {
|
|
350
|
+
let confirmBtn;
|
|
351
|
+
const gateWord = t('settings.import.gateWord');
|
|
352
|
+
const input = Input({
|
|
353
|
+
placeholder: gateWord,
|
|
354
|
+
onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !== gateWord; },
|
|
355
|
+
onKeydown: (e) => {
|
|
356
|
+
if (e.key === 'Enter' && e.target.value.trim() === gateWord && confirmBtn && !confirmBtn.disabled) confirmBtn.click();
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
openModal({
|
|
361
|
+
title: t('settings.import.title'),
|
|
362
|
+
size: 'lg',
|
|
363
|
+
body: el('div', { class: 'stack-3' },
|
|
364
|
+
el('div', { class: 'callout' },
|
|
365
|
+
icon('info'),
|
|
366
|
+
el('div', {},
|
|
367
|
+
el('div', { class: 'callout__title' }, t('settings.import.calloutTitle')),
|
|
368
|
+
el('div', { class: 'callout__body' },
|
|
369
|
+
t('settings.import.calloutBody')))),
|
|
370
|
+
el('dl', { class: 'kv' },
|
|
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)),
|
|
375
|
+
previewRows(preview.counts),
|
|
376
|
+
preview.warnings?.length
|
|
377
|
+
? el('div', { class: 'callout' },
|
|
378
|
+
icon('info'),
|
|
379
|
+
el('div', {},
|
|
380
|
+
el('div', { class: 'callout__title' }, t('settings.import.warningsTitle')),
|
|
381
|
+
el('div', { class: 'callout__body' }, preview.warnings.join(' '))))
|
|
382
|
+
: null,
|
|
383
|
+
el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } },
|
|
384
|
+
t('settings.import.instruction', { word: gateWord })),
|
|
385
|
+
input),
|
|
386
|
+
footer: (close) => {
|
|
387
|
+
confirmBtn = Btn(t('settings.import.confirm'), {
|
|
388
|
+
icon: 'upload',
|
|
389
|
+
variant: 'danger',
|
|
390
|
+
disabled: true,
|
|
391
|
+
onClick: async () => {
|
|
392
|
+
confirmBtn.disabled = true;
|
|
393
|
+
try {
|
|
394
|
+
await post('/api/settings/restore', { backup, confirm: 'RESTORE' });
|
|
395
|
+
close();
|
|
396
|
+
toastOk(t('settings.import.doneToast'));
|
|
397
|
+
await rerender();
|
|
398
|
+
} catch (e) {
|
|
399
|
+
toastError(e);
|
|
400
|
+
confirmBtn.disabled = input.value.trim() !== gateWord;
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
return [Btn(t('settings.cancel'), { onClick: close }), confirmBtn];
|
|
405
|
+
},
|
|
225
406
|
});
|
|
226
407
|
}
|
|
227
408
|
|
|
228
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
|
+
|
|
229
426
|
function DangerZone(ctx) {
|
|
230
427
|
function openResetModal() {
|
|
231
428
|
let confirmBtn;
|
|
232
|
-
// Gate on the
|
|
429
|
+
// Gate on the locale-resolved word so there's no IME switch and it matches the button.
|
|
233
430
|
// (The API still expects the fixed 'DELETE' token; we send that on the wire below.)
|
|
431
|
+
const gateWord = t('settings.reset.gateWord');
|
|
234
432
|
const input = Input({
|
|
235
|
-
placeholder:
|
|
236
|
-
onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !==
|
|
237
|
-
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(); },
|
|
238
436
|
});
|
|
239
437
|
|
|
240
438
|
openModal({
|
|
241
|
-
title: '
|
|
439
|
+
title: t('settings.reset.title'),
|
|
242
440
|
body: el('div', { class: 'stack-3' },
|
|
243
441
|
el('div', { class: 'callout' },
|
|
244
442
|
icon('info'),
|
|
245
443
|
el('div', {},
|
|
246
|
-
el('div', { class: 'callout__title' }, '
|
|
444
|
+
el('div', { class: 'callout__title' }, t('settings.reset.calloutTitle')),
|
|
247
445
|
el('div', { class: 'callout__body' },
|
|
248
|
-
'
|
|
446
|
+
t('settings.reset.calloutBody')))),
|
|
249
447
|
el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } },
|
|
250
|
-
|
|
448
|
+
t('settings.reset.instruction', { word: gateWord })),
|
|
251
449
|
input),
|
|
252
450
|
footer: (close) => {
|
|
253
|
-
confirmBtn = Btn('
|
|
451
|
+
confirmBtn = Btn(t('settings.reset.confirm'), {
|
|
254
452
|
variant: 'danger',
|
|
255
453
|
disabled: true,
|
|
256
454
|
onClick: async () => {
|
|
@@ -258,28 +456,28 @@ function DangerZone(ctx) {
|
|
|
258
456
|
try {
|
|
259
457
|
await post('/api/settings/reset', { confirm: 'DELETE' });
|
|
260
458
|
close();
|
|
261
|
-
toastOk('
|
|
459
|
+
toastOk(t('settings.reset.doneToast'));
|
|
262
460
|
navigate('/');
|
|
263
461
|
await ctx.refreshNav();
|
|
264
462
|
} catch (e) {
|
|
265
463
|
toastError(e);
|
|
266
|
-
confirmBtn.disabled = input.value.trim() !==
|
|
464
|
+
confirmBtn.disabled = input.value.trim() !== gateWord;
|
|
267
465
|
}
|
|
268
466
|
},
|
|
269
467
|
});
|
|
270
|
-
return [Btn('
|
|
468
|
+
return [Btn(t('settings.cancel'), { onClick: close }), confirmBtn];
|
|
271
469
|
},
|
|
272
470
|
});
|
|
273
471
|
}
|
|
274
472
|
|
|
275
473
|
return Card({
|
|
276
|
-
title: '
|
|
277
|
-
sub: '
|
|
474
|
+
title: t('settings.danger.title'),
|
|
475
|
+
sub: t('settings.danger.sub'),
|
|
278
476
|
body: el('div', { class: 'flex between gap-3 wrap' },
|
|
279
477
|
el('div', { style: { maxWidth: '440px' } },
|
|
280
|
-
el('div', { class: 'strong' }, '
|
|
478
|
+
el('div', { class: 'strong' }, t('settings.danger.resetTitle')),
|
|
281
479
|
el('div', { class: 'muted text-sm', style: { lineHeight: '1.6' } },
|
|
282
|
-
'
|
|
283
|
-
Btn('
|
|
480
|
+
t('settings.danger.resetBody'))),
|
|
481
|
+
Btn(t('settings.danger.resetTitle'), { icon: 'trash', variant: 'danger', onClick: openResetModal })),
|
|
284
482
|
});
|
|
285
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
|
|
|
@@ -190,10 +190,10 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
190
190
|
|
|
191
191
|
/* ----------------------------------------------------------------- card */
|
|
192
192
|
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
|
|
193
|
-
.card__head { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
|
|
193
|
+
.card__head { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
194
194
|
.card__head h3 { font-size: 15px; }
|
|
195
195
|
.card__head .sub { color: var(--text-tertiary); font-size: 13px; }
|
|
196
|
-
.card__head .right { margin-left: auto; display: flex; gap: 6px; }
|
|
196
|
+
.card__head .right { margin-left: auto; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; min-width: 0; max-width: 100%; }
|
|
197
197
|
.card__body { padding: 18px; }
|
|
198
198
|
.card__body--tight { padding: 0; }
|
|
199
199
|
/* a light bordered block for grouping inside a card/modal — never nest a full .card */
|
|
@@ -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; }
|
|
@@ -417,6 +444,10 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
417
444
|
/* min-width:0 overrides a grid item's default min-width:auto so the 1fr column
|
|
418
445
|
can shrink below its content — otherwise a long path/value overflows the grid. */
|
|
419
446
|
.kv dd { margin: 0; min-width: 0; }
|
|
447
|
+
.settings-action-row { align-items: flex-start; min-width: 0; }
|
|
448
|
+
.settings-action-row__main { flex: 1 1 auto; min-width: 0; }
|
|
449
|
+
.settings-action-row__main > div:last-child { min-width: 0; }
|
|
450
|
+
.settings-action-row > .btn { flex: 0 0 auto; }
|
|
420
451
|
|
|
421
452
|
/* ----------------------------------------------------------------- modal */
|
|
422
453
|
.modal-root { position: fixed; inset: 0; z-index: 60; display: none; }
|
|
@@ -505,5 +536,9 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
505
536
|
.kv{ grid-template-columns: 1fr; gap: 0; }
|
|
506
537
|
.kv dt{ margin-top: 10px; }
|
|
507
538
|
.kv dd{ margin: 0 0 2px; }
|
|
539
|
+
.settings-action-row{ align-items: stretch; flex-direction: column; }
|
|
540
|
+
.settings-action-row > .btn{ width: 100%; justify-content: center; }
|
|
541
|
+
.card__head{ align-items: flex-start; flex-wrap: wrap; }
|
|
542
|
+
.card__head .right{ margin-left: 0; width: 100%; justify-content: flex-start; }
|
|
508
543
|
.page-head{ flex-wrap: wrap; }
|
|
509
544
|
}
|