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
@@ -1,26 +1,30 @@
1
- // Documents — cover letters (with version history) and résumés/other documents.
1
+ // Documents — cover letters (with version history), career descriptions, and résumés/other documents.
2
2
  // XSS-safe: all DB/user text rendered via el() textContent / .doc-preview (never innerHTML).
3
3
  import {
4
4
  el, get, post, put, del, Card, Badge, Btn, IconBtn, SubmitBtn, EmptyState,
5
5
  Field, Input, Textarea, Select, openModal, confirmDialog,
6
6
  toastOk, toastError, copyText, downloadUrl, fmtRelative, mount, meta,
7
7
  } from '/lib.js';
8
+ import { t } from '/i18n.js';
8
9
 
9
- const SOURCE_LABELS = { manual: '직접 입력', upload: '파일 업로드', ai: 'AI 생성', edit: '직접 수정' };
10
- const sourceLabel = (s) => SOURCE_LABELS[s] || s || '';
11
-
12
- // Module-scoped on purpose: remember the last-viewed tab ('cover' | 'docs') across
10
+ // Module-scoped on purpose: remember the last-viewed tab ('cover' | 'career' | 'docs') across
13
11
  // navigations so returning to Documents reopens where the user left off. The search
14
12
  // query is intentionally NOT persisted (it resets per visit).
15
13
  let tab = 'cover';
14
+ let openedDeepLink = '';
16
15
 
17
16
  export async function render(ctx) {
17
+ const requestedTab = ['cover', 'career', 'docs'].includes(ctx.params?.[0]) ? ctx.params[0] : null;
18
+ const requestedId = ctx.params?.[1] || '';
19
+ if (requestedTab) tab = requestedTab;
20
+
18
21
  const root = el('div', { class: 'stack-4' });
19
22
 
20
23
  const panel = el('div', {});
21
- const tabCover = el('div', { class: `tab${tab === 'cover' ? ' is-active' : ''}`, onClick: () => selectTab('cover') }, '자기소개서');
22
- const tabDocs = el('div', { class: `tab${tab === 'docs' ? ' is-active' : ''}`, onClick: () => selectTab('docs') }, '이력서·문서');
23
- root.append(el('div', { class: 'tabs' }, tabCover, tabDocs), panel);
24
+ const tabCover = el('div', { class: `tab${tab === 'cover' ? ' is-active' : ''}`, onClick: () => selectTab('cover') }, t('documents.tab.cover'));
25
+ const tabCareer = el('div', { class: `tab${tab === 'career' ? ' is-active' : ''}`, onClick: () => selectTab('career') }, t('documents.tab.career'));
26
+ const tabDocs = el('div', { class: `tab${tab === 'docs' ? ' is-active' : ''}`, onClick: () => selectTab('docs') }, t('documents.tab.docs'));
27
+ root.append(el('div', { class: 'tabs' }, tabCover, tabCareer, tabDocs), panel);
24
28
 
25
29
  let query = '';
26
30
  let searchInput = null;
@@ -28,6 +32,7 @@ export async function render(ctx) {
28
32
  tab = next;
29
33
  query = '';
30
34
  tabCover.classList.toggle('is-active', tab === 'cover');
35
+ tabCareer.classList.toggle('is-active', tab === 'career');
31
36
  tabDocs.classList.toggle('is-active', tab === 'docs');
32
37
  setActions();
33
38
  renderPanel();
@@ -47,8 +52,8 @@ export async function render(ctx) {
47
52
  if (existing) existing.remove();
48
53
  if (q && cards.length && shown === 0) {
49
54
  panel.append(el('div', { class: 'search-empty empty' },
50
- el('p', { class: 'muted', style: { margin: '0 0 12px' } }, `‘${query.trim()}’에 맞는 항목이 없어요.`),
51
- Btn('필터 초기화', { sm: true, variant: 'ghost', onClick: () => { query = ''; if (searchInput) searchInput.value = ''; applyFilter(); } })));
55
+ el('p', { class: 'muted', style: { margin: '0 0 12px' } }, t('documents.search.empty', { query: query.trim() })),
56
+ Btn(t('documents.search.clear'), { sm: true, variant: 'ghost', onClick: () => { query = ''; if (searchInput) searchInput.value = ''; applyFilter(); } })));
52
57
  }
53
58
  }
54
59
 
@@ -58,11 +63,13 @@ export async function render(ctx) {
58
63
  }
59
64
 
60
65
  async function renderPanel() {
61
- mount(panel, el('div', { class: 'muted text-sm' }, '불러오는 중…'));
66
+ mount(panel, el('div', { class: 'muted text-sm' }, t('documents.loading')));
62
67
  try {
63
68
  if (tab === 'cover') mount(panel, await CoverLettersTab(reload));
69
+ else if (tab === 'career') mount(panel, await CareerDescriptionsTab(reload));
64
70
  else mount(panel, await DocumentsTab(reload));
65
71
  applyFilter();
72
+ await openRequestedDocument();
66
73
  } catch (err) {
67
74
  toastError(err);
68
75
  mount(panel, el('div', { class: 'card' }, el('div', { class: 'card__body' },
@@ -70,15 +77,31 @@ export async function render(ctx) {
70
77
  }
71
78
  }
72
79
 
80
+ async function openRequestedDocument() {
81
+ if (!requestedId || requestedTab !== tab) return;
82
+ const key = `${requestedTab}:${requestedId}`;
83
+ if (openedDeepLink === key) return;
84
+ openedDeepLink = key;
85
+ if (tab === 'cover') {
86
+ await openCoverDetail(requestedId, reload);
87
+ return;
88
+ }
89
+ const m = await meta();
90
+ const kindLabels = Object.fromEntries((m.document_kinds || []).map((k) => [k.value, t('kind.' + k.value)]));
91
+ await openDocDetail(requestedId, kindLabels, m, reload);
92
+ }
93
+
73
94
  // topbar: search (filters the active tab) + the tab's create action
74
95
  function setActions() {
75
- const search = Input({ type: 'search', placeholder: tab === 'cover' ? '자기소개서 검색' : '문서 검색', value: query, attrs: { 'aria-label': '검색' } });
96
+ const placeholder = tab === 'cover' ? t('documents.search.cover') : tab === 'career' ? t('documents.search.career') : t('documents.search.docs');
97
+ const search = Input({ type: 'search', placeholder, value: query, attrs: { 'aria-label': t('documents.search.aria') } });
76
98
  search.classList.add('input--inline');
77
99
  searchInput = search;
78
100
  search.addEventListener('input', () => { query = search.value; applyFilter(); });
79
- const createBtn = tab === 'cover'
80
- ? Btn('새 자기소개서', { icon: 'plus', variant: 'primary', onClick: () => openCoverCreate(reload) })
81
- : Btn('문서 추가', { icon: 'plus', variant: 'primary', onClick: () => openDocCreate(reload) });
101
+ let createBtn;
102
+ if (tab === 'cover') createBtn = Btn(t('documents.action.coverCreate'), { icon: 'plus', variant: 'primary', onClick: () => openCoverCreate(reload) });
103
+ else if (tab === 'career') createBtn = Btn(t('documents.action.careerCreate'), { icon: 'plus', variant: 'primary', onClick: () => openDocCreate(reload, 'career_description') });
104
+ else createBtn = Btn(t('documents.action.docCreate'), { icon: 'plus', variant: 'primary', onClick: () => openDocCreate(reload) });
82
105
  ctx.setActions([search, createBtn]);
83
106
  }
84
107
 
@@ -96,9 +119,9 @@ async function CoverLettersTab(reload) {
96
119
  if (!list.length) {
97
120
  return mountInto(wrap, EmptyState({
98
121
  iconName: 'file',
99
- title: '아직 자기소개서가 없어요',
100
- body: '작성하거나 붙여넣어 버전과 함께 보관하세요.',
101
- action: Btn('새 자기소개서', { icon: 'plus', variant: 'primary', onClick: () => openCoverCreate(reload) }),
122
+ title: t('documents.cover.empty.title'),
123
+ body: t('documents.cover.empty.body'),
124
+ action: Btn(t('documents.action.coverCreate'), { icon: 'plus', variant: 'primary', onClick: () => openCoverCreate(reload) }),
102
125
  }));
103
126
  }
104
127
 
@@ -108,17 +131,17 @@ async function CoverLettersTab(reload) {
108
131
 
109
132
  function CoverRow(cl, reload) {
110
133
  const metaRow = el('div', { class: 'flex gap-2 wrap', style: { marginTop: '4px' } },
111
- el('span', { class: 'chip' }, `버전 ${cl.version_count}개`),
112
- cl.is_primary ? Badge('accent', '대표') : null,
134
+ el('span', { class: 'chip' }, t('documents.cover.versionCount', { count: cl.version_count })),
135
+ cl.is_primary ? Badge('accent', t('documents.cover.primary')) : null,
113
136
  );
114
137
 
115
138
  const actions = el('div', { class: 'flex gap-2 wrap' },
116
- Btn('열기', { sm: true, icon: 'external', onClick: () => openCoverDetail(cl.id, reload) }),
117
- Btn('복사', { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(cl.current_content || ''), disabled: !cl.current_content }),
118
- Btn('MD', { sm: true, variant: 'ghost', icon: 'download', title: 'Markdown 내보내기', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=md`) }),
119
- Btn('HTML', { sm: true, variant: 'ghost', icon: 'download', title: 'HTML 내보내기', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=html`) }),
120
- cl.is_primary ? null : Btn('대표 지정', { sm: true, variant: 'ghost', icon: 'check', onClick: () => setPrimaryCover(cl.id, reload) }),
121
- IconBtn('trash', { variant: 'danger', title: '삭제', onClick: () => removeCover(cl, reload) }),
139
+ Btn(t('documents.cover.open'), { sm: true, icon: 'external', onClick: () => openCoverDetail(cl.id, reload) }),
140
+ Btn(t('documents.cover.copy'), { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(cl.current_content || ''), disabled: !cl.current_content }),
141
+ Btn(t('documents.cover.exportMd'), { sm: true, variant: 'ghost', icon: 'download', title: t('documents.cover.exportMdTitle'), onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=md`) }),
142
+ Btn(t('documents.cover.exportHtml'), { sm: true, variant: 'ghost', icon: 'download', title: t('documents.cover.exportHtmlTitle'), onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=html`) }),
143
+ cl.is_primary ? null : Btn(t('documents.cover.setPrimary'), { sm: true, variant: 'ghost', icon: 'check', onClick: () => setPrimaryCover(cl.id, reload) }),
144
+ IconBtn('trash', { variant: 'danger', title: t('documents.cover.delete'), onClick: () => removeCover(cl, reload) }),
122
145
  );
123
146
 
124
147
  return Card({
@@ -127,7 +150,7 @@ function CoverRow(cl, reload) {
127
150
  metaRow,
128
151
  cl.current_content
129
152
  ? el('p', { class: 'text-secondary text-sm truncate', style: { margin: '8px 0 0', maxWidth: '100%' } }, firstLine(cl.current_content))
130
- : el('p', { class: 'muted text-sm', style: { margin: '8px 0 0' } }, '아직 내용이 없습니다.'),
153
+ : el('p', { class: 'muted text-sm', style: { margin: '8px 0 0' } }, t('documents.cover.noContent')),
131
154
  el('div', { class: 'divider' }),
132
155
  actions,
133
156
  ],
@@ -137,21 +160,21 @@ function CoverRow(cl, reload) {
137
160
  async function setPrimaryCover(id, reload) {
138
161
  try {
139
162
  await put(`/api/cover-letters/${id}/primary`, {});
140
- toastOk('대표 자기소개서로 지정했어요.');
163
+ toastOk(t('documents.cover.setPrimaryToast'));
141
164
  await reload();
142
165
  } catch (err) { toastError(err); }
143
166
  }
144
167
 
145
168
  async function removeCover(cl, reload) {
146
169
  const ok = await confirmDialog({
147
- title: '자기소개서 삭제',
148
- message: `"${cl.title}"을(를) 삭제할까요? 모든 버전 기록이 함께 삭제됩니다.`,
149
- confirmLabel: '삭제', danger: true,
170
+ title: t('documents.cover.deleteTitle'),
171
+ message: t('documents.cover.deleteConfirm', { title: cl.title }),
172
+ confirmLabel: t('documents.cover.deleteConfirmLabel'), danger: true,
150
173
  });
151
174
  if (!ok) return;
152
175
  try {
153
176
  await del(`/api/cover-letters/${cl.id}`);
154
- toastOk('자기소개서를 삭제했어요.');
177
+ toastOk(t('documents.cover.deleteToast'));
155
178
  await reload();
156
179
  } catch (err) { toastError(err); }
157
180
  }
@@ -160,32 +183,32 @@ async function openCoverCreate(reload) {
160
183
  // Let the user PICK the job to link instead of typing an internal ID they've never seen.
161
184
  let jobs = [];
162
185
  try { ({ jobs } = await get('/api/jobs')); } catch { /* dropdown falls back to "연결 안 함" */ }
163
- const title = Input({ placeholder: '예: 백엔드 개발자 자기소개서', attrs: { maxlength: '200' } });
164
- const content = Textarea({ placeholder: '내용을 붙여넣으세요. 비워 두면 빈 자기소개서가 만들어집니다.', style: { minHeight: '200px' } });
186
+ const title = Input({ placeholder: t('documents.coverCreate.titlePlaceholder'), attrs: { maxlength: '200' } });
187
+ const content = Textarea({ placeholder: t('documents.coverCreate.contentPlaceholder'), style: { minHeight: '200px' } });
165
188
  const jobPick = Select([
166
- { value: '', label: '연결 안 함', selected: true },
189
+ { value: '', label: t('documents.coverCreate.jobNone'), selected: true },
167
190
  ...jobs.map((j) => ({ value: j.id, label: `${j.company} · ${j.position}` })),
168
191
  ]);
169
192
 
170
193
  openModal({
171
- title: '새 자기소개서',
194
+ title: t('documents.coverCreate.title'),
172
195
  size: 'lg',
173
196
  body: el('div', { class: 'stack-3' },
174
- Field('제목 (필수)', title),
175
- Field('내용 (선택)', content, '내용을 입력하면 첫 버전으로 저장됩니다.'),
176
- Field('연결할 공고 (선택)', jobPick,
177
- jobs.length ? '특정 공고에 연결하면 그 공고 화면에서 함께 보여요.' : '저장된 공고가 아직 없어요.'),
197
+ Field(t('documents.coverCreate.fieldTitle'), title),
198
+ Field(t('documents.coverCreate.fieldContent'), content, t('documents.coverCreate.fieldContentHint')),
199
+ Field(t('documents.coverCreate.fieldJob'), jobPick,
200
+ jobs.length ? t('documents.coverCreate.fieldJobHint') : t('documents.coverCreate.fieldJobHintEmpty')),
178
201
  ),
179
202
  footer: (close) => [
180
- Btn('취소', { onClick: close }),
181
- SubmitBtn('자기소개서 저장', async () => {
182
- const t = title.value.trim();
183
- if (!t) { title.focus(); throw new Error('제목을 입력해 주세요.'); }
184
- const body = { title: t, source: 'manual' };
203
+ Btn(t('documents.coverCreate.cancel'), { onClick: close }),
204
+ SubmitBtn(t('documents.coverCreate.submit'), async () => {
205
+ const tv = title.value.trim();
206
+ if (!tv) { title.focus(); throw new Error(t('documents.coverCreate.titleRequired')); }
207
+ const body = { title: tv, source: 'manual' };
185
208
  if (content.value.trim()) body.content = content.value;
186
209
  if (jobPick.value) body.job_id = jobPick.value;
187
210
  await post('/api/cover-letters', body);
188
- toastOk('자기소개서를 추가했어요.');
211
+ toastOk(t('documents.coverCreate.toast'));
189
212
  close();
190
213
  await reload();
191
214
  }),
@@ -228,25 +251,25 @@ function CoverDetailBody(cl, { refresh }) {
228
251
  // header meta + export
229
252
  wrap.append(el('div', { class: 'flex between wrap gap-2' },
230
253
  el('div', { class: 'flex gap-2 wrap' },
231
- el('span', { class: 'chip' }, `버전 ${cl.version_count}개`),
232
- cl.is_primary ? Badge('accent', '대표') : null,
254
+ el('span', { class: 'chip' }, t('documents.cover.versionCount', { count: cl.version_count })),
255
+ cl.is_primary ? Badge('accent', t('documents.cover.primary')) : null,
233
256
  ),
234
257
  el('div', { class: 'flex gap-2 wrap' },
235
- Btn('MD 내보내기', { sm: true, variant: 'ghost', icon: 'download', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=md`) }),
236
- Btn('HTML 내보내기', { sm: true, variant: 'ghost', icon: 'download', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=html`) }),
258
+ Btn(t('documents.coverDetail.exportMd'), { sm: true, variant: 'ghost', icon: 'download', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=md`) }),
259
+ Btn(t('documents.coverDetail.exportHtml'), { sm: true, variant: 'ghost', icon: 'download', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=html`) }),
237
260
  ),
238
261
  ));
239
262
 
240
263
  // preview — defaults to current content; can be swapped per-version
241
264
  const previewLabel = el('div', { class: 'flex between center' },
242
- el('h3', { style: { fontSize: '14px', margin: 0 } }, '현재 버전'),
243
- Btn('복사', { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(preview.textContent || '') }),
265
+ el('h3', { style: { fontSize: '14px', margin: 0 } }, t('documents.coverDetail.currentVersion')),
266
+ Btn(t('documents.coverDetail.copy'), { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(preview.textContent || '') }),
244
267
  );
245
- const preview = el('div', { class: 'doc-preview' }, cl.current_content || '내용이 없습니다.');
268
+ const preview = el('div', { class: 'doc-preview' }, cl.current_content || t('documents.coverDetail.noContent'));
246
269
 
247
270
  const setPreview = (label, text) => {
248
271
  previewLabel.firstChild.textContent = label;
249
- mount(preview, text || '내용이 없습니다.');
272
+ mount(preview, text || t('documents.coverDetail.noContent'));
250
273
  };
251
274
 
252
275
  wrap.append(el('div', { class: 'stack-2' }, previewLabel, preview));
@@ -256,11 +279,11 @@ function CoverDetailBody(cl, { refresh }) {
256
279
 
257
280
  // version history timeline
258
281
  wrap.append(el('div', { class: 'stack-2' },
259
- el('h3', { style: { fontSize: '14px', margin: 0 } }, '버전 기록'),
282
+ el('h3', { style: { fontSize: '14px', margin: 0 } }, t('documents.coverDetail.history')),
260
283
  versions.length
261
284
  ? el('div', { class: 'timeline' }, ...versions.map((v) =>
262
285
  VersionItem(cl, v, { isCurrent: v.id === cl.current_version_id, setPreview, refresh })))
263
- : el('p', { class: 'muted', style: { margin: 0 } }, '아직 버전 기록이 없습니다.'),
286
+ : el('p', { class: 'muted', style: { margin: 0 } }, t('documents.coverDetail.historyEmpty')),
264
287
  ));
265
288
 
266
289
  return wrap;
@@ -269,9 +292,9 @@ function CoverDetailBody(cl, { refresh }) {
269
292
  function VersionItem(cl, v, { isCurrent, setPreview, refresh }) {
270
293
  const head = el('div', { class: 'flex between wrap gap-2 center' },
271
294
  el('div', { class: 'flex gap-2 center wrap' },
272
- el('span', { class: 'strong' }, `v${v.version_no}`),
273
- isCurrent ? Badge('applied', '현재 버전') : null,
274
- el('span', { class: 'chip' }, sourceLabel(v.source)),
295
+ el('span', { class: 'strong' }, t('documents.version.label', { n: v.version_no })),
296
+ isCurrent ? Badge('applied', t('documents.version.current')) : null,
297
+ el('span', { class: 'chip' }, t('source.' + v.source)),
275
298
  ),
276
299
  el('span', { class: 'muted text-sm' }, fmtRelative(v.created_at)),
277
300
  );
@@ -279,9 +302,9 @@ function VersionItem(cl, v, { isCurrent, setPreview, refresh }) {
279
302
  const note = v.note ? el('div', { class: 'text-secondary text-sm', style: { marginTop: '4px' } }, v.note) : null;
280
303
 
281
304
  const actions = el('div', { class: 'flex gap-2 wrap', style: { marginTop: '8px' } },
282
- Btn('이 버전 보기', { sm: true, variant: 'ghost', icon: 'external', onClick: () => setPreview(`v${v.version_no} 보기`, v.content) }),
283
- isCurrent ? null : Btn('현재 버전으로 지정', { sm: true, variant: 'ghost', icon: 'check', onClick: () => makeCurrent(cl.id, v.id, refresh) }),
284
- Btn('복사', { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(v.content || '') }),
305
+ Btn(t('documents.version.view'), { sm: true, variant: 'ghost', icon: 'external', onClick: () => setPreview(t('documents.version.viewLabel', { n: v.version_no }), v.content) }),
306
+ isCurrent ? null : Btn(t('documents.version.makeCurrent'), { sm: true, variant: 'ghost', icon: 'check', onClick: () => makeCurrent(cl.id, v.id, refresh) }),
307
+ Btn(t('documents.version.copy'), { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(v.content || '') }),
285
308
  );
286
309
 
287
310
  return el('div', { class: `tl-item${isCurrent ? ' is-current' : ''}` },
@@ -296,7 +319,7 @@ function VersionItem(cl, v, { isCurrent, setPreview, refresh }) {
296
319
  async function makeCurrent(id, versionId, refresh) {
297
320
  try {
298
321
  await put(`/api/cover-letters/${id}/current-version`, { version_id: versionId });
299
- toastOk('현재 버전을 변경했어요.');
322
+ toastOk(t('documents.version.makeCurrentToast'));
300
323
  await refresh();
301
324
  } catch (err) { toastError(err); }
302
325
  }
@@ -305,25 +328,25 @@ function EditNewVersion(cl, refresh) {
305
328
  const editor = el('div', { class: 'stack-3', style: { display: 'none' } });
306
329
  const content = Textarea({ style: { minHeight: '220px' } });
307
330
  content.value = cl.current_content || '';
308
- const note = Input({ placeholder: '예: 지원동기 보강, 문장 다듬기' });
331
+ const note = Input({ placeholder: t('documents.editVersion.notePlaceholder') });
309
332
 
310
333
  editor.append(
311
- Field('내용', content),
312
- Field('변경 메모 (선택)', note),
334
+ Field(t('documents.editVersion.fieldContent'), content),
335
+ Field(t('documents.editVersion.fieldNote'), note),
313
336
  el('div', { class: 'flex gap-2' },
314
- SubmitBtn('새 버전으로 저장', async () => {
315
- if (!content.value.trim()) { content.focus(); throw new Error('내용을 입력해 주세요.'); }
337
+ SubmitBtn(t('documents.editVersion.submit'), async () => {
338
+ if (!content.value.trim()) { content.focus(); throw new Error(t('documents.editVersion.contentRequired')); }
316
339
  await post(`/api/cover-letters/${cl.id}/versions`, {
317
340
  content: content.value, note: note.value.trim() || undefined, source: 'edit',
318
341
  });
319
- toastOk('새 버전을 저장했어요.');
342
+ toastOk(t('documents.editVersion.toast'));
320
343
  await refresh();
321
344
  }, { icon: 'check' }),
322
- Btn('취소', { variant: 'ghost', onClick: () => { editor.style.display = 'none'; toggle.style.display = ''; } }),
345
+ Btn(t('documents.editVersion.cancel'), { variant: 'ghost', onClick: () => { editor.style.display = 'none'; toggle.style.display = ''; } }),
323
346
  ),
324
347
  );
325
348
 
326
- const toggle = Btn('수정하여 새 버전 저장', {
349
+ const toggle = Btn(t('documents.editVersion.toggle'), {
327
350
  variant: 'primary', icon: 'edit',
328
351
  onClick: () => {
329
352
  content.value = cl.current_content || '';
@@ -336,19 +359,40 @@ function EditNewVersion(cl, refresh) {
336
359
  return el('div', { class: 'stack-2' }, toggle, editor);
337
360
  }
338
361
 
339
- /* ============================================================ Tab B — 이력서·문서 */
362
+ /* ============================================================ Tab B — 경력기술서 */
363
+
364
+ async function CareerDescriptionsTab(reload) {
365
+ const [{ documents: list }, m] = await Promise.all([get('/api/documents?kind=career_description'), meta()]);
366
+ const kindLabels = Object.fromEntries((m.document_kinds || []).map((k) => [k.value, t('kind.' + k.value)]));
367
+ const wrap = el('div', { class: 'stack-3' });
368
+
369
+ if (!list.length) {
370
+ return mountInto(wrap, EmptyState({
371
+ iconName: 'file',
372
+ title: t('documents.career.empty.title'),
373
+ body: t('documents.career.empty.body'),
374
+ action: Btn(t('documents.career.empty.action'), { icon: 'plus', variant: 'primary', onClick: () => openDocCreate(reload, 'career_description') }),
375
+ }));
376
+ }
377
+
378
+ for (const doc of list) wrap.append(DocRow(doc, kindLabels, m, reload));
379
+ return wrap;
380
+ }
381
+
382
+ /* ============================================================ Tab C — 이력서·포트폴리오 */
340
383
 
341
384
  async function DocumentsTab(reload) {
342
- const [{ documents: list }, m] = await Promise.all([get('/api/documents'), meta()]);
343
- const kindLabels = Object.fromEntries((m.document_kinds || []).map((k) => [k.value, k.label]));
385
+ const [{ documents }, m] = await Promise.all([get('/api/documents'), meta()]);
386
+ const list = (documents || []).filter((doc) => doc.kind !== 'career_description');
387
+ const kindLabels = Object.fromEntries((m.document_kinds || []).map((k) => [k.value, t('kind.' + k.value)]));
344
388
  const wrap = el('div', { class: 'stack-3' });
345
389
 
346
390
  if (!list.length) {
347
391
  return mountInto(wrap, EmptyState({
348
392
  iconName: 'file',
349
- title: '저장된 문서가 없어요',
350
- body: '이력서·경력기술서·포트폴리오 텍스트를 보관하세요.',
351
- action: Btn('문서 추가', { icon: 'plus', variant: 'primary', onClick: () => openDocCreate(reload) }),
393
+ title: t('documents.docs.empty.title'),
394
+ body: t('documents.docs.empty.body'),
395
+ action: Btn(t('documents.docs.empty.action'), { icon: 'plus', variant: 'primary', onClick: () => openDocCreate(reload) }),
352
396
  }));
353
397
  }
354
398
 
@@ -361,7 +405,7 @@ function DocRow(doc, kindLabels, m, reload) {
361
405
  el('div', { class: 'flex gap-2 wrap center' },
362
406
  el('span', { class: 'strong' }, doc.title),
363
407
  el('span', { class: 'chip' }, kindLabels[doc.kind] || doc.kind),
364
- doc.is_primary ? Badge('accent', '대표') : null,
408
+ doc.is_primary ? Badge('accent', t('documents.cover.primary')) : null,
365
409
  ),
366
410
  el('span', { class: 'muted text-sm' }, fmtRelative(doc.updated_at)),
367
411
  );
@@ -394,61 +438,63 @@ async function openDocDetail(id, kindLabels, m, reload) {
394
438
  body: el('div', { class: 'stack-3' },
395
439
  el('div', { class: 'flex gap-2 wrap center' },
396
440
  el('span', { class: 'chip' }, kindLabels[doc.kind] || doc.kind),
397
- doc.is_primary ? Badge('accent', '대표') : null,
398
- el('span', { class: 'chip' }, sourceLabel(doc.source)),
399
- el('span', { class: 'muted text-sm' }, `수정 ${fmtRelative(doc.updated_at)}`),
441
+ doc.is_primary ? Badge('accent', t('documents.cover.primary')) : null,
442
+ el('span', { class: 'chip' }, t('source.' + doc.source)),
443
+ el('span', { class: 'muted text-sm' }, t('documents.docDetail.updated', { when: fmtRelative(doc.updated_at) })),
400
444
  ),
401
- el('div', { class: 'doc-preview' }, doc.content || '내용이 없습니다.'),
445
+ el('div', { class: 'doc-preview' }, doc.content || t('documents.docDetail.noContent')),
402
446
  ),
403
447
  footer: (close) => [
404
- Btn('복사', { variant: 'ghost', icon: 'copy', onClick: () => copyText(doc.content || '') }),
405
- Btn('내보내기', { variant: 'ghost', icon: 'download', title: 'Markdown 내보내기', onClick: () => downloadUrl(`/api/export/document/${doc.id}?format=md`) }),
406
- Btn('삭제', { variant: 'danger', icon: 'trash', onClick: async () => {
407
- const ok = await confirmDialog({ title: '문서 삭제', message: `"${doc.title}"을(를) 삭제할까요?`, confirmLabel: '삭제', danger: true });
448
+ Btn(t('documents.docDetail.copy'), { variant: 'ghost', icon: 'copy', onClick: () => copyText(doc.content || '') }),
449
+ Btn(t('documents.docDetail.export'), { variant: 'ghost', icon: 'download', title: t('documents.docDetail.exportTitle'), onClick: () => downloadUrl(`/api/export/document/${doc.id}?format=md`) }),
450
+ Btn(t('documents.docDetail.exportHtml'), { variant: 'ghost', icon: 'download', title: t('documents.docDetail.exportHtmlTitle'), onClick: () => downloadUrl(`/api/export/document/${doc.id}?format=html`) }),
451
+ Btn(t('documents.docDetail.delete'), { variant: 'danger', icon: 'trash', onClick: async () => {
452
+ const ok = await confirmDialog({ title: t('documents.docDetail.deleteTitle'), message: t('documents.docDetail.deleteConfirm', { title: doc.title }), confirmLabel: t('documents.docDetail.deleteConfirmLabel'), danger: true });
408
453
  if (!ok) return;
409
- try { await del(`/api/documents/${doc.id}`); toastOk('문서를 삭제했어요.'); close(); await reload(); }
454
+ try { await del(`/api/documents/${doc.id}`); toastOk(t('documents.docDetail.deleteToast')); close(); await reload(); }
410
455
  catch (err) { toastError(err); }
411
456
  } }),
412
- Btn('수정', { variant: 'primary', icon: 'edit', onClick: () => { close(); openDocEdit(doc, m, reload); } }),
457
+ Btn(t('documents.docDetail.edit'), { variant: 'primary', icon: 'edit', onClick: () => { close(); openDocEdit(doc, m, reload); } }),
413
458
  ],
414
459
  });
415
460
  }
416
461
 
417
- function openDocCreate(reload) {
418
- meta().then((m) => docForm(null, m, reload)).catch(toastError);
462
+ function openDocCreate(reload, defaultKind = 'resume') {
463
+ meta().then((m) => docForm(null, m, reload, defaultKind)).catch(toastError);
419
464
  }
420
465
 
421
466
  function openDocEdit(doc, m, reload) {
422
- docForm(doc, m, reload);
467
+ docForm(doc, m, reload, doc?.kind || 'resume');
423
468
  }
424
469
 
425
- function docForm(doc, m, reload) {
426
- const kinds = (m.document_kinds || []).map((k) => ({ value: k.value, label: k.label, selected: doc ? doc.kind === k.value : k.value === 'resume' }));
470
+ function docForm(doc, m, reload, defaultKind = 'resume') {
471
+ const kindLabel = (m.document_kinds || []).some((k) => k.value === defaultKind) ? t('kind.' + defaultKind) : t('documents.docForm.defaultKind');
472
+ const kinds = (m.document_kinds || []).map((k) => ({ value: k.value, label: t('kind.' + k.value), selected: doc ? doc.kind === k.value : k.value === defaultKind }));
427
473
  const kind = Select(kinds);
428
- const title = Input({ value: doc?.title || '', placeholder: '예: 2026 이력서', attrs: { maxlength: '200' } });
429
- const content = Textarea({ placeholder: '이력서·경력기술서 본문을 붙여넣으세요.', style: { minHeight: '300px' } });
474
+ const title = Input({ value: doc?.title || '', placeholder: defaultKind === 'career_description' ? t('documents.docForm.titlePlaceholderCareer') : t('documents.docForm.titlePlaceholderDefault'), attrs: { maxlength: '200' } });
475
+ const content = Textarea({ placeholder: defaultKind === 'career_description' ? t('documents.docForm.contentPlaceholderCareer') : t('documents.docForm.contentPlaceholderDefault'), style: { minHeight: '300px' } });
430
476
  content.value = doc?.content || '';
431
477
  const primary = el('input', { type: 'checkbox', checked: !!doc?.is_primary });
432
478
 
433
479
  openModal({
434
- title: doc ? '문서 수정' : '문서 추가',
480
+ title: doc ? t('documents.docForm.editTitle') : t('documents.docForm.createTitle', { kind: kindLabel }),
435
481
  size: 'lg',
436
482
  body: el('div', { class: 'stack-3' },
437
- Field('종류', kind),
438
- Field('제목 (필수)', title),
439
- Field('내용 (필수)', content),
440
- Field(null, el('label', { class: 'flex gap-2 center', style: { cursor: 'pointer' } }, primary, el('span', {}, '대표 문서로 지정'))),
483
+ Field(t('documents.docForm.fieldKind'), kind),
484
+ Field(t('documents.docForm.fieldTitle'), title),
485
+ Field(t('documents.docForm.fieldContent'), content),
486
+ Field(null, el('label', { class: 'flex gap-2 center', style: { cursor: 'pointer' } }, primary, el('span', {}, t('documents.docForm.markPrimary')))),
441
487
  ),
442
488
  footer: (close) => [
443
- Btn('취소', { onClick: close }),
444
- SubmitBtn('문서 저장', async () => {
445
- const t = title.value.trim();
446
- if (!t) { title.focus(); throw new Error('제목을 입력해 주세요.'); }
447
- if (!content.value.trim()) { content.focus(); throw new Error('내용을 입력해 주세요.'); }
448
- const body = { kind: kind.value, title: t, content: content.value, is_primary: primary.checked };
489
+ Btn(t('documents.docForm.cancel'), { onClick: close }),
490
+ SubmitBtn(t('documents.docForm.submit'), async () => {
491
+ const tv = title.value.trim();
492
+ if (!tv) { title.focus(); throw new Error(t('documents.docForm.titleRequired')); }
493
+ if (!content.value.trim()) { content.focus(); throw new Error(t('documents.docForm.contentRequired')); }
494
+ const body = { kind: kind.value, title: tv, content: content.value, is_primary: primary.checked };
449
495
  if (doc) await put(`/api/documents/${doc.id}`, body);
450
496
  else await post('/api/documents', { ...body, source: 'manual' });
451
- toastOk(doc ? '문서를 수정했어요.' : '문서를 추가했어요.');
497
+ toastOk(doc ? t('documents.docForm.editToast') : t('documents.docForm.createToast'));
452
498
  close();
453
499
  await reload();
454
500
  }),