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.
Files changed (124) hide show
  1. package/AGENTS.md +11 -7
  2. package/INSTALL.md +18 -16
  3. package/README.md +28 -26
  4. package/THIRD_PARTY_NOTICES.md +2 -3
  5. package/bin/careermate.mjs +8 -1
  6. package/dist/career-os/README.md +8 -3
  7. package/dist/career-os/eop/career-description.md +136 -0
  8. package/dist/career-os/eop/cover-letter.md +7 -5
  9. package/dist/career-os/eop/fit-analysis.md +15 -5
  10. package/dist/career-os/eop/interview-prep.md +209 -21
  11. package/dist/career-os/eop/job-analysis.md +5 -3
  12. package/dist/career-os/eop/profile-extraction.md +5 -4
  13. package/dist/career-os/knowledge/cover-letter.md +4 -4
  14. package/dist/career-os/knowledge/fit-matching.md +8 -8
  15. package/dist/career-os/knowledge/interview-behavioral.md +76 -8
  16. package/dist/career-os/knowledge/interview-technical.md +106 -4
  17. package/dist/career-os/knowledge/linkedin-profile.md +1 -1
  18. package/dist/career-os/knowledge/networking-referrals.md +1 -1
  19. package/dist/career-os/knowledge/offer-evaluation-decision.md +10 -10
  20. package/dist/career-os/knowledge/portfolio.md +3 -3
  21. package/dist/career-os/knowledge/verifiers/ats-compat.md +8 -4
  22. package/dist/career-os/knowledge/verifiers/human-voice.md +2 -2
  23. package/dist/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  24. package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  25. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -1
  26. package/dist/doctor.mjs +1019 -127
  27. package/dist/mcp.mjs +1036 -126
  28. package/dist/migrate.mjs +52 -1
  29. package/dist/public/app.js +42 -16
  30. package/dist/public/i18n/en/applications.js +23 -0
  31. package/dist/public/i18n/en/common.js +22 -0
  32. package/dist/public/i18n/en/documents.js +133 -0
  33. package/dist/public/i18n/en/home.js +75 -0
  34. package/dist/public/i18n/en/interview.js +69 -0
  35. package/dist/public/i18n/en/jobs.js +152 -0
  36. package/dist/public/i18n/en/nav.js +18 -0
  37. package/dist/public/i18n/en/profile.js +201 -0
  38. package/dist/public/i18n/en/settings.js +127 -0
  39. package/dist/public/i18n/en/status.js +22 -0
  40. package/dist/public/i18n/en.js +17 -0
  41. package/dist/public/i18n/ko/applications.js +23 -0
  42. package/dist/public/i18n/ko/common.js +22 -0
  43. package/dist/public/i18n/ko/documents.js +133 -0
  44. package/dist/public/i18n/ko/home.js +63 -0
  45. package/dist/public/i18n/ko/interview.js +69 -0
  46. package/dist/public/i18n/ko/jobs.js +152 -0
  47. package/dist/public/i18n/ko/nav.js +18 -0
  48. package/dist/public/i18n/ko/profile.js +201 -0
  49. package/dist/public/i18n/ko/settings.js +127 -0
  50. package/dist/public/i18n/ko/status.js +23 -0
  51. package/dist/public/i18n/ko.js +18 -0
  52. package/dist/public/i18n.js +55 -0
  53. package/dist/public/index.html +1 -0
  54. package/dist/public/lib.js +25 -17
  55. package/dist/public/pages/applications.js +17 -21
  56. package/dist/public/pages/documents.js +153 -107
  57. package/dist/public/pages/home.js +88 -53
  58. package/dist/public/pages/interview.js +105 -51
  59. package/dist/public/pages/jobs.js +330 -106
  60. package/dist/public/pages/profile.js +157 -152
  61. package/dist/public/pages/settings.js +270 -72
  62. package/dist/public/styles.css +38 -3
  63. package/dist/web.mjs +859 -103
  64. package/docs/FAQ.md +26 -5
  65. package/docs/MCP_TOOLS.md +26 -12
  66. package/docs/START_WORKFLOW.md +2 -2
  67. package/docs/SUPPORTED_AI_APPS.md +13 -0
  68. package/docs/WORKFLOWS.md +80 -23
  69. package/docs/career-os/README.md +8 -3
  70. package/docs/career-os/eop/career-description.md +136 -0
  71. package/docs/career-os/eop/cover-letter.md +7 -5
  72. package/docs/career-os/eop/fit-analysis.md +15 -5
  73. package/docs/career-os/eop/interview-prep.md +209 -21
  74. package/docs/career-os/eop/job-analysis.md +5 -3
  75. package/docs/career-os/eop/profile-extraction.md +5 -4
  76. package/docs/career-os/knowledge/cover-letter.md +4 -4
  77. package/docs/career-os/knowledge/fit-matching.md +8 -8
  78. package/docs/career-os/knowledge/interview-behavioral.md +76 -8
  79. package/docs/career-os/knowledge/interview-technical.md +106 -4
  80. package/docs/career-os/knowledge/linkedin-profile.md +1 -1
  81. package/docs/career-os/knowledge/networking-referrals.md +1 -1
  82. package/docs/career-os/knowledge/offer-evaluation-decision.md +10 -10
  83. package/docs/career-os/knowledge/portfolio.md +3 -3
  84. package/docs/career-os/knowledge/verifiers/ats-compat.md +8 -4
  85. package/docs/career-os/knowledge/verifiers/human-voice.md +2 -2
  86. package/docs/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  87. package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  88. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -1
  89. package/package.json +2 -2
  90. package/packages/core/src/onboarding.ts +2 -1
  91. package/packages/core/src/services.ts +138 -21
  92. package/packages/core/src/summary.ts +59 -1
  93. package/packages/core/src/verify/lint.ts +4 -0
  94. package/packages/db/src/index.ts +1 -0
  95. package/packages/db/src/repositories.ts +0 -0
  96. package/packages/db/src/schema.ts +17 -0
  97. package/packages/exporters/src/index.ts +28 -17
  98. package/packages/knowledge/src/index.ts +46 -7
  99. package/packages/mcp-tools/src/bridge.ts +194 -13
  100. package/packages/mcp-tools/src/tools.ts +390 -38
  101. package/packages/mcp-tools/src/update.ts +31 -16
  102. package/packages/parsers/src/documents.ts +12 -1
  103. package/packages/prompts/src/humanize.ts +46 -0
  104. package/packages/prompts/src/index.ts +5 -1
  105. package/packages/prompts/src/install.ts +4 -4
  106. package/packages/prompts/src/onboarding.ts +4 -4
  107. package/packages/prompts/src/system.ts +17 -8
  108. package/packages/shared/src/enums.ts +14 -4
  109. package/packages/shared/src/schemas.ts +41 -0
  110. package/packages/workflows/src/definitions.ts +47 -10
  111. package/scripts/init.ts +284 -13
  112. package/dist/site/analytics.js +0 -97
  113. package/dist/site/favicon.svg +0 -1
  114. package/dist/site/fonts/PretendardVariable.woff2 +0 -0
  115. package/dist/site/icon-180.png +0 -0
  116. package/dist/site/icon-512.png +0 -0
  117. package/dist/site/index.html +0 -565
  118. package/dist/site/landing.js +0 -54
  119. package/dist/site/llms-install.txt +0 -218
  120. package/dist/site/robots.txt +0 -4
  121. package/dist/site/sitemap.xml +0 -21
  122. package/dist/site/start.html +0 -353
  123. package/dist/site/style.css +0 -559
  124. 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
- const COUNT_LABELS = {
9
- profile: '프로필', experiences: '경력', projects: '프로젝트', skills: '기술',
10
- documents: '문서', cover_letters: '자기소개서', jobs: '채용공고',
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: '복사', onClick: () => copyText(value) })
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('데이터 폴더', info.data_dir, true),
47
- ...kvRow('데이터베이스', info.db_path, true),
48
- ...kvRow('앱 버전', info.version, false),
49
- ...kvRow('Node 버전', info.node_version, false),
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
- '모든 데이터는 이 컴퓨터에만 저장됩니다. 외부로 전송되지 않습니다. AI에는 사용자가 직접 입력하거나 AI가 MCP로 조회한 정보만 전달됩니다.')));
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 dashRow = el('div', { class: 'flex between gap-3', style: { padding: '4px 0' } },
68
- el('div', { class: 'flex gap-3', style: { alignItems: 'center' } },
69
- el('span', { class: 'badge badge--final_passed' }, el('span', { class: 'dot' }), '실행 중'),
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
- info.url
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' }, 'MCP 서버'),
110
+ el('div', { class: 'strong' }, t('settings.connection.mcpServer')),
82
111
  el('div', { class: 'muted text-sm', style: { maxWidth: '440px', lineHeight: '1.6' } },
83
- 'MCP 서버는 사용하시는 AI 클라이언트(Claude·ChatGPT·Cursor)가 직접 실행하며, 이 대시보드와 같은 데이터베이스를 공유합니다.'))),
84
- Btn('설치·연결 안내 열기', { icon: 'external', sm: true, variant: 'ghost', onClick: () => window.open('/install', '_blank') }));
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', label: '라이트' },
102
- { value: 'dark', label: '다크' },
103
- { value: 'system', label: '시스템' },
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.label, { variant: 'ghost', onClick: () => applyTheme(o.value) });
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, label: '기본', desc: '저장된 경력·이력서·프로젝트에 아예 근거가 없는 수치만 차단' },
143
- { value: true, label: '엄격', desc: '이력서 본문에 없고 구조화 경력/프로젝트 항목에만 있는 수치까지 차단' },
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.label, { variant: 'ghost', onClick: () => set(o.value) });
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' }, '기본'), ' — ', options[0].desc),
172
- el('div', {}, el('span', { class: 'strong' }, '엄격'), ' — ', options[1].desc),
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
- '엄격 모드에선 올린 이력서에 없는 수치가 막힐 수 있어요. 막히면 이력서를 올리거나 모드를 끄세요. AI에게 "엄격하게 봐줘"라고 해도 켤 수 있습니다.'));
232
+ t('settings.verify.note')));
175
233
 
176
234
  return Card({
177
- title: '자소서 수치 점검',
178
- sub: 'AI가 쓴 수치가 내 실제 데이터에 근거가 있는지 저장 전에 자동으로 확인합니다',
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 = Object.entries(COUNT_LABELS).map(([key, label]) =>
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' }, 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', {}, '파일'), el('th', {}, '생성'), el('th', { style: { textAlign: 'right' } }, '크기'))),
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' } }, `${Math.round((b.size || 0) / 1024)} KB`)))))
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('백업 생성', { icon: 'copy', sm: true, variant: 'primary', onClick: createBackup }),
222
- Btn('전체 데이터 내보내기(JSON)', { icon: 'download', sm: true, variant: 'ghost', onClick: () => downloadUrl('/api/settings/export-all') }),
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 Korean word so there's no IME switch and it matches the '초기화' button.
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() === '초기화' && confirmBtn && !confirmBtn.disabled) confirmBtn.click(); },
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
- '계속하려면 아래 입력란에 ', el('span', { class: 'strong' }, '초기화'), ' 를 입력하세요.'),
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() !== 'DELETE';
464
+ confirmBtn.disabled = input.value.trim() !== gateWord;
267
465
  }
268
466
  },
269
467
  });
270
- return [Btn('취소', { onClick: close }), confirmBtn];
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
- '이 컴퓨터에 저장된 모든 CareerMate 데이터를 삭제합니다. 초기화 전에 자동으로 백업이 생성됩니다.')),
283
- Btn('모든 데이터 초기화', { icon: 'trash', variant: 'danger', onClick: openResetModal })),
480
+ t('settings.danger.resetBody'))),
481
+ Btn(t('settings.danger.resetTitle'), { icon: 'trash', variant: 'danger', onClick: openResetModal })),
284
482
  });
285
483
  }
@@ -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
  }