careermate 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/AGENTS.md +33 -0
  2. package/INSTALL.md +230 -0
  3. package/LICENSE +21 -0
  4. package/README.md +532 -0
  5. package/THIRD_PARTY_NOTICES.md +57 -0
  6. package/bin/careermate.mjs +77 -0
  7. package/dist/career-os/README.md +78 -0
  8. package/dist/career-os/eop/cover-letter.md +46 -0
  9. package/dist/career-os/eop/fit-analysis.md +48 -0
  10. package/dist/career-os/eop/interview-prep.md +39 -0
  11. package/dist/career-os/eop/job-analysis.md +42 -0
  12. package/dist/career-os/eop/profile-extraction.md +37 -0
  13. package/dist/career-os/knowledge/ats.md +131 -0
  14. package/dist/career-os/knowledge/company-research.md +137 -0
  15. package/dist/career-os/knowledge/cover-letter.md +135 -0
  16. package/dist/career-os/knowledge/fit-matching.md +121 -0
  17. package/dist/career-os/knowledge/human-writing.md +118 -0
  18. package/dist/career-os/knowledge/interview-behavioral.md +132 -0
  19. package/dist/career-os/knowledge/interview-technical.md +126 -0
  20. package/dist/career-os/knowledge/linkedin-profile.md +152 -0
  21. package/dist/career-os/knowledge/networking-referrals.md +166 -0
  22. package/dist/career-os/knowledge/offer-evaluation-decision.md +121 -0
  23. package/dist/career-os/knowledge/onboarding-first-90-days.md +126 -0
  24. package/dist/career-os/knowledge/portfolio.md +121 -0
  25. package/dist/career-os/knowledge/recruiter-screen.md +105 -0
  26. package/dist/career-os/knowledge/rejection-triage-iteration.md +115 -0
  27. package/dist/career-os/knowledge/resume.md +123 -0
  28. package/dist/career-os/knowledge/salary-negotiation.md +123 -0
  29. package/dist/career-os/knowledge/verifiers/ats-compat.md +64 -0
  30. package/dist/career-os/knowledge/verifiers/consistency.md +61 -0
  31. package/dist/career-os/knowledge/verifiers/human-voice.md +82 -0
  32. package/dist/career-os/knowledge/verifiers/recency-staleness.md +72 -0
  33. package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +82 -0
  34. package/dist/career-os/knowledge/verifiers/truthfulness.md +67 -0
  35. package/dist/doctor.mjs +79094 -0
  36. package/dist/mcp.mjs +96108 -0
  37. package/dist/migrate.mjs +4735 -0
  38. package/dist/pdf.worker.min.mjs +28 -0
  39. package/dist/public/app.js +150 -0
  40. package/dist/public/fonts/PretendardVariable.woff2 +0 -0
  41. package/dist/public/icon-180.png +0 -0
  42. package/dist/public/index.html +52 -0
  43. package/dist/public/lib.js +394 -0
  44. package/dist/public/pages/applications.js +107 -0
  45. package/dist/public/pages/documents.js +467 -0
  46. package/dist/public/pages/home.js +261 -0
  47. package/dist/public/pages/interview.js +227 -0
  48. package/dist/public/pages/jobs.js +493 -0
  49. package/dist/public/pages/profile.js +556 -0
  50. package/dist/public/pages/settings.js +240 -0
  51. package/dist/public/styles.css +498 -0
  52. package/dist/site/analytics.js +97 -0
  53. package/dist/site/favicon.svg +1 -0
  54. package/dist/site/fonts/PretendardVariable.woff2 +0 -0
  55. package/dist/site/icon-180.png +0 -0
  56. package/dist/site/icon-512.png +0 -0
  57. package/dist/site/index.html +598 -0
  58. package/dist/site/landing.js +54 -0
  59. package/dist/site/llms-install.txt +217 -0
  60. package/dist/site/robots.txt +4 -0
  61. package/dist/site/sitemap.xml +21 -0
  62. package/dist/site/start.html +326 -0
  63. package/dist/site/style.css +550 -0
  64. package/dist/site/vercel.json +20 -0
  65. package/dist/web.mjs +46852 -0
  66. package/docs/FAQ.md +101 -0
  67. package/docs/MCP_TOOLS.md +311 -0
  68. package/docs/README.md +28 -0
  69. package/docs/START_WORKFLOW.md +137 -0
  70. package/docs/SUPPORTED_AI_APPS.md +112 -0
  71. package/docs/WORKFLOWS.md +202 -0
  72. package/docs/career-os/README.md +78 -0
  73. package/docs/career-os/eop/cover-letter.md +46 -0
  74. package/docs/career-os/eop/fit-analysis.md +48 -0
  75. package/docs/career-os/eop/interview-prep.md +39 -0
  76. package/docs/career-os/eop/job-analysis.md +42 -0
  77. package/docs/career-os/eop/profile-extraction.md +37 -0
  78. package/docs/career-os/knowledge/ats.md +131 -0
  79. package/docs/career-os/knowledge/company-research.md +137 -0
  80. package/docs/career-os/knowledge/cover-letter.md +135 -0
  81. package/docs/career-os/knowledge/fit-matching.md +121 -0
  82. package/docs/career-os/knowledge/human-writing.md +118 -0
  83. package/docs/career-os/knowledge/interview-behavioral.md +132 -0
  84. package/docs/career-os/knowledge/interview-technical.md +126 -0
  85. package/docs/career-os/knowledge/linkedin-profile.md +152 -0
  86. package/docs/career-os/knowledge/networking-referrals.md +166 -0
  87. package/docs/career-os/knowledge/offer-evaluation-decision.md +121 -0
  88. package/docs/career-os/knowledge/onboarding-first-90-days.md +126 -0
  89. package/docs/career-os/knowledge/portfolio.md +121 -0
  90. package/docs/career-os/knowledge/recruiter-screen.md +105 -0
  91. package/docs/career-os/knowledge/rejection-triage-iteration.md +115 -0
  92. package/docs/career-os/knowledge/resume.md +123 -0
  93. package/docs/career-os/knowledge/salary-negotiation.md +123 -0
  94. package/docs/career-os/knowledge/verifiers/ats-compat.md +64 -0
  95. package/docs/career-os/knowledge/verifiers/consistency.md +61 -0
  96. package/docs/career-os/knowledge/verifiers/human-voice.md +82 -0
  97. package/docs/career-os/knowledge/verifiers/recency-staleness.md +72 -0
  98. package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +82 -0
  99. package/docs/career-os/knowledge/verifiers/truthfulness.md +67 -0
  100. package/package.json +89 -0
  101. package/packages/core/src/context.ts +74 -0
  102. package/packages/core/src/index.ts +8 -0
  103. package/packages/core/src/onboarding.ts +81 -0
  104. package/packages/core/src/services.ts +187 -0
  105. package/packages/core/src/summary.ts +104 -0
  106. package/packages/db/src/connection.ts +76 -0
  107. package/packages/db/src/index.ts +23 -0
  108. package/packages/db/src/paths.ts +52 -0
  109. package/packages/db/src/repositories.ts +891 -0
  110. package/packages/db/src/runtime.ts +58 -0
  111. package/packages/db/src/schema.ts +307 -0
  112. package/packages/exporters/src/html.ts +110 -0
  113. package/packages/exporters/src/index.ts +364 -0
  114. package/packages/exporters/src/markdown.ts +178 -0
  115. package/packages/knowledge/src/index.ts +168 -0
  116. package/packages/mcp-tools/src/bridge.ts +106 -0
  117. package/packages/mcp-tools/src/index.ts +17 -0
  118. package/packages/mcp-tools/src/result.ts +49 -0
  119. package/packages/mcp-tools/src/tools.ts +747 -0
  120. package/packages/mcp-tools/src/update.ts +330 -0
  121. package/packages/parsers/src/documents.ts +533 -0
  122. package/packages/parsers/src/html.ts +86 -0
  123. package/packages/parsers/src/index.ts +231 -0
  124. package/packages/parsers/src/keywords.ts +151 -0
  125. package/packages/prompts/src/humanize.ts +59 -0
  126. package/packages/prompts/src/index.ts +82 -0
  127. package/packages/prompts/src/install.ts +43 -0
  128. package/packages/prompts/src/onboarding.ts +35 -0
  129. package/packages/prompts/src/system.ts +57 -0
  130. package/packages/shared/src/build-info.ts +22 -0
  131. package/packages/shared/src/enums.ts +142 -0
  132. package/packages/shared/src/index.ts +19 -0
  133. package/packages/shared/src/schemas.ts +453 -0
  134. package/packages/workflows/src/definitions.ts +109 -0
  135. package/packages/workflows/src/index.ts +39 -0
  136. package/scripts/init.ts +371 -0
  137. package/tsconfig.json +32 -0
@@ -0,0 +1,107 @@
1
+ // Applications — every application grouped by status in a calm VERTICAL list
2
+ // (no horizontal scroll). Change a status inline to move an item between groups.
3
+ import {
4
+ el, get, put, navigate, EmptyState, Btn, ListRow,
5
+ Select, scoreClass, statusColor, mount, toast, toastOk, toastError, meta,
6
+ } from '/lib.js';
7
+
8
+ // deadline(YYYY-MM-DD) → "마감 D-7" / "마감 D-day" / "마감 지남" (null if absent/invalid)
9
+ function ddayLabel(deadline) {
10
+ if (!deadline) return null;
11
+ const d = new Date(`${deadline}T00:00:00`);
12
+ if (isNaN(d.getTime())) return null;
13
+ const today = new Date();
14
+ today.setHours(0, 0, 0, 0);
15
+ const days = Math.round((d.getTime() - today.getTime()) / 86400000);
16
+ if (days > 0) return `마감 D-${days}`;
17
+ if (days === 0) return '마감 D-day';
18
+ return '마감 지남';
19
+ }
20
+
21
+ export async function render(ctx) {
22
+ ctx.setActions([]);
23
+ const data = await get('/api/applications');
24
+ const applications = data.applications || [];
25
+ const boardOrder = data.board_order || [];
26
+
27
+ if (!applications.length) {
28
+ mount(ctx.view, el('div', { class: 'stack-4' }, EmptyState({
29
+ iconName: 'layers',
30
+ title: '아직 지원 내역이 없어요',
31
+ body: '공고를 추가하면 지원이 상태별로 정리됩니다.',
32
+ action: Btn('공고 보러 가기', { icon: 'briefcase', variant: 'primary', onClick: () => navigate('/jobs') }),
33
+ })));
34
+ return;
35
+ }
36
+
37
+ // Status labels: board_order is authoritative for ordering; fall back to meta() labels.
38
+ const labelByCode = {};
39
+ for (const a of applications) if (a.status && a.status_label) labelByCode[a.status] = a.status_label;
40
+ let metaStatuses = [];
41
+ try { metaStatuses = (await meta()).statuses || []; } catch { /* labels fall back to codes */ }
42
+ for (const s of metaStatuses) if (!labelByCode[s.value]) labelByCode[s.value] = s.label;
43
+ const statusOptions = boardOrder.map((code) => ({ value: code, label: labelByCode[code] || code }));
44
+
45
+ // Group by status.
46
+ const byStatus = {};
47
+ for (const app of applications) (byStatus[app.status] || (byStatus[app.status] = [])).push(app);
48
+
49
+ const wrap = el('div', { class: 'stack-4' });
50
+ for (const code of boardOrder) {
51
+ const apps = byStatus[code];
52
+ if (apps && apps.length) wrap.append(StatusGroup(code, apps));
53
+ }
54
+ mount(ctx.view, wrap);
55
+
56
+ function StatusGroup(code, apps) {
57
+ return el('div', {},
58
+ el('div', { class: 'app-group__head' },
59
+ el('span', { class: 'app-group__dot', style: { background: statusColor(code) } }),
60
+ el('span', { class: 'app-group__title' }, labelByCode[code] || code),
61
+ el('span', { class: 'app-group__count tnum' }, `${apps.length}건`)),
62
+ el('div', {}, ...apps.map(AppRow)));
63
+ }
64
+
65
+ function AppRow(app) {
66
+ const job = app.job || {};
67
+ const subBits = [job.position, job.location].filter(Boolean);
68
+ const dday = ddayLabel(job.deadline);
69
+ if (dday) subBits.push(dday);
70
+ const sel = Select(
71
+ statusOptions.map((o) => ({ ...o, selected: o.value === app.status })),
72
+ {
73
+ class: 'select select--sm row-action',
74
+ title: '상태 변경',
75
+ onClick: (e) => e.stopPropagation(),
76
+ onChange: (e) => { e.stopPropagation(); changeStatus(app, e.target.value, e.target); },
77
+ },
78
+ );
79
+ return ListRow({
80
+ title: job.company || '—',
81
+ sub: subBits.join(' · '),
82
+ trailing: [
83
+ app.fit_score != null
84
+ ? el('span', { class: `strong tnum ${scoreClass(app.fit_score)}` }, `${app.fit_score}점`)
85
+ : null,
86
+ sel,
87
+ ],
88
+ onClick: () => navigate(`/jobs/${app.job_id}`),
89
+ });
90
+ }
91
+
92
+ async function changeStatus(app, newValue, selectEl) {
93
+ if (!newValue || newValue === app.status) return;
94
+ selectEl.disabled = true;
95
+ try {
96
+ const res = await put(`/api/applications/${app.job_id}/status`, { status: newValue });
97
+ toastOk('지원 상태를 변경했어요.');
98
+ if (res && res.hint) toast(res.hint, { title: '면접 준비', type: 'default' });
99
+ await ctx.refreshNav();
100
+ await render(ctx); // refetch + re-render so the item moves to its new group
101
+ } catch (err) {
102
+ toastError(err);
103
+ selectEl.value = app.status; // revert on failure
104
+ selectEl.disabled = false;
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,467 @@
1
+ // Documents — cover letters (with version history) and résumés/other documents.
2
+ // XSS-safe: all DB/user text rendered via el() textContent / .doc-preview (never innerHTML).
3
+ import {
4
+ el, get, post, put, del, Card, Badge, Btn, IconBtn, SubmitBtn, EmptyState,
5
+ Field, Input, Textarea, Select, openModal, confirmDialog,
6
+ toastOk, toastError, copyText, downloadUrl, fmtRelative, mount, meta,
7
+ } from '/lib.js';
8
+
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
13
+ // navigations so returning to Documents reopens where the user left off. The search
14
+ // query is intentionally NOT persisted (it resets per visit).
15
+ let tab = 'cover';
16
+
17
+ export async function render(ctx) {
18
+ const root = el('div', { class: 'stack-4' });
19
+
20
+ 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
+
25
+ let query = '';
26
+ let searchInput = null;
27
+ function selectTab(next) {
28
+ tab = next;
29
+ query = '';
30
+ tabCover.classList.toggle('is-active', tab === 'cover');
31
+ tabDocs.classList.toggle('is-active', tab === 'docs');
32
+ setActions();
33
+ renderPanel();
34
+ }
35
+
36
+ // Client-side filter over the rendered cards; survives panel re-renders.
37
+ function applyFilter() {
38
+ const q = query.trim().toLowerCase();
39
+ const cards = [...panel.querySelectorAll('.card')];
40
+ let shown = 0;
41
+ for (const c of cards) {
42
+ const hide = !!q && !c.textContent.toLowerCase().includes(q);
43
+ c.classList.toggle('hide', hide);
44
+ if (!hide) shown += 1;
45
+ }
46
+ const existing = panel.querySelector('.search-empty');
47
+ if (existing) existing.remove();
48
+ if (q && cards.length && shown === 0) {
49
+ 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(); } })));
52
+ }
53
+ }
54
+
55
+ async function reload() {
56
+ await renderPanel();
57
+ await ctx.refreshNav();
58
+ }
59
+
60
+ async function renderPanel() {
61
+ mount(panel, el('div', { class: 'muted text-sm' }, '불러오는 중…'));
62
+ try {
63
+ if (tab === 'cover') mount(panel, await CoverLettersTab(reload));
64
+ else mount(panel, await DocumentsTab(reload));
65
+ applyFilter();
66
+ } catch (err) {
67
+ toastError(err);
68
+ mount(panel, el('div', { class: 'card' }, el('div', { class: 'card__body' },
69
+ el('p', { class: 'text-secondary' }, err instanceof Error ? err.message : String(err)))));
70
+ }
71
+ }
72
+
73
+ // topbar: search (filters the active tab) + the tab's create action
74
+ function setActions() {
75
+ const search = Input({ type: 'search', placeholder: tab === 'cover' ? '자기소개서 검색' : '문서 검색', value: query, attrs: { 'aria-label': '검색' } });
76
+ search.classList.add('input--inline');
77
+ searchInput = search;
78
+ 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) });
82
+ ctx.setActions([search, createBtn]);
83
+ }
84
+
85
+ mount(ctx.view, root);
86
+ setActions();
87
+ await renderPanel();
88
+ }
89
+
90
+ /* ============================================================ Tab A — 자기소개서 */
91
+
92
+ async function CoverLettersTab(reload) {
93
+ const { cover_letters: list } = await get('/api/cover-letters');
94
+ const wrap = el('div', { class: 'stack-3' });
95
+
96
+ if (!list.length) {
97
+ return mountInto(wrap, EmptyState({
98
+ iconName: 'file',
99
+ title: '아직 자기소개서가 없어요',
100
+ body: '작성하거나 붙여넣어 버전과 함께 보관하세요.',
101
+ action: Btn('새 자기소개서', { icon: 'plus', variant: 'primary', onClick: () => openCoverCreate(reload) }),
102
+ }));
103
+ }
104
+
105
+ for (const cl of list) wrap.append(CoverRow(cl, reload));
106
+ return wrap;
107
+ }
108
+
109
+ function CoverRow(cl, reload) {
110
+ 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,
113
+ );
114
+
115
+ 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) }),
122
+ );
123
+
124
+ return Card({
125
+ title: cl.title,
126
+ body: [
127
+ metaRow,
128
+ cl.current_content
129
+ ? 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' } }, '아직 내용이 없습니다.'),
131
+ el('div', { class: 'divider' }),
132
+ actions,
133
+ ],
134
+ });
135
+ }
136
+
137
+ async function setPrimaryCover(id, reload) {
138
+ try {
139
+ await put(`/api/cover-letters/${id}/primary`, {});
140
+ toastOk('대표 자기소개서로 지정했어요.');
141
+ await reload();
142
+ } catch (err) { toastError(err); }
143
+ }
144
+
145
+ async function removeCover(cl, reload) {
146
+ const ok = await confirmDialog({
147
+ title: '자기소개서 삭제',
148
+ message: `"${cl.title}"을(를) 삭제할까요? 모든 버전 기록이 함께 삭제됩니다.`,
149
+ confirmLabel: '삭제', danger: true,
150
+ });
151
+ if (!ok) return;
152
+ try {
153
+ await del(`/api/cover-letters/${cl.id}`);
154
+ toastOk('자기소개서를 삭제했어요.');
155
+ await reload();
156
+ } catch (err) { toastError(err); }
157
+ }
158
+
159
+ async function openCoverCreate(reload) {
160
+ // Let the user PICK the job to link instead of typing an internal ID they've never seen.
161
+ let jobs = [];
162
+ 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' } });
165
+ const jobPick = Select([
166
+ { value: '', label: '연결 안 함', selected: true },
167
+ ...jobs.map((j) => ({ value: j.id, label: `${j.company} · ${j.position}` })),
168
+ ]);
169
+
170
+ openModal({
171
+ title: '새 자기소개서',
172
+ size: 'lg',
173
+ body: el('div', { class: 'stack-3' },
174
+ Field('제목 (필수)', title),
175
+ Field('내용 (선택)', content, '내용을 입력하면 첫 버전으로 저장됩니다.'),
176
+ Field('연결할 공고 (선택)', jobPick,
177
+ jobs.length ? '특정 공고에 연결하면 그 공고 화면에서 함께 보여요.' : '저장된 공고가 아직 없어요.'),
178
+ ),
179
+ 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' };
185
+ if (content.value.trim()) body.content = content.value;
186
+ if (jobPick.value) body.job_id = jobPick.value;
187
+ await post('/api/cover-letters', body);
188
+ toastOk('자기소개서를 추가했어요.');
189
+ close();
190
+ await reload();
191
+ }),
192
+ ],
193
+ });
194
+ }
195
+
196
+ async function openCoverDetail(id, reload) {
197
+ let data;
198
+ try {
199
+ data = (await get(`/api/cover-letters/${id}`)).cover_letter;
200
+ } catch (err) {
201
+ toastError(err);
202
+ return;
203
+ }
204
+
205
+ openModal({
206
+ title: data.title,
207
+ size: 'lg',
208
+ body: (close) => {
209
+ const container = el('div', {});
210
+ const draw = (cl) => mount(container, CoverDetailBody(cl, {
211
+ refresh: async () => {
212
+ data = (await get(`/api/cover-letters/${id}`)).cover_letter;
213
+ draw(data);
214
+ await reload();
215
+ },
216
+ onClose: close,
217
+ }));
218
+ draw(data);
219
+ return container;
220
+ },
221
+ });
222
+ }
223
+
224
+ function CoverDetailBody(cl, { refresh }) {
225
+ const versions = (cl.versions || []).slice().sort((a, b) => b.version_no - a.version_no);
226
+ const wrap = el('div', { class: 'stack-4' });
227
+
228
+ // header meta + export
229
+ wrap.append(el('div', { class: 'flex between wrap gap-2' },
230
+ el('div', { class: 'flex gap-2 wrap' },
231
+ el('span', { class: 'chip' }, `버전 ${cl.version_count}개`),
232
+ cl.is_primary ? Badge('accent', '대표') : null,
233
+ ),
234
+ 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`) }),
237
+ ),
238
+ ));
239
+
240
+ // preview — defaults to current content; can be swapped per-version
241
+ 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 || '') }),
244
+ );
245
+ const preview = el('div', { class: 'doc-preview' }, cl.current_content || '내용이 없습니다.');
246
+
247
+ const setPreview = (label, text) => {
248
+ previewLabel.firstChild.textContent = label;
249
+ mount(preview, text || '내용이 없습니다.');
250
+ };
251
+
252
+ wrap.append(el('div', { class: 'stack-2' }, previewLabel, preview));
253
+
254
+ // edit -> new version
255
+ wrap.append(EditNewVersion(cl, refresh));
256
+
257
+ // version history timeline
258
+ wrap.append(el('div', { class: 'stack-2' },
259
+ el('h3', { style: { fontSize: '14px', margin: 0 } }, '버전 기록'),
260
+ versions.length
261
+ ? el('div', { class: 'timeline' }, ...versions.map((v) =>
262
+ VersionItem(cl, v, { isCurrent: v.id === cl.current_version_id, setPreview, refresh })))
263
+ : el('p', { class: 'muted', style: { margin: 0 } }, '아직 버전 기록이 없습니다.'),
264
+ ));
265
+
266
+ return wrap;
267
+ }
268
+
269
+ function VersionItem(cl, v, { isCurrent, setPreview, refresh }) {
270
+ const head = el('div', { class: 'flex between wrap gap-2 center' },
271
+ 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)),
275
+ ),
276
+ el('span', { class: 'muted text-sm' }, fmtRelative(v.created_at)),
277
+ );
278
+
279
+ const note = v.note ? el('div', { class: 'text-secondary text-sm', style: { marginTop: '4px' } }, v.note) : null;
280
+
281
+ 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 || '') }),
285
+ );
286
+
287
+ return el('div', { class: `tl-item${isCurrent ? ' is-current' : ''}` },
288
+ el('div', { class: 'tl-item__rail' },
289
+ el('div', { class: 'tl-item__dot' }),
290
+ el('div', { class: 'tl-item__line' }),
291
+ ),
292
+ el('div', { class: 'tl-item__body' }, head, note, actions),
293
+ );
294
+ }
295
+
296
+ async function makeCurrent(id, versionId, refresh) {
297
+ try {
298
+ await put(`/api/cover-letters/${id}/current-version`, { version_id: versionId });
299
+ toastOk('현재 버전을 변경했어요.');
300
+ await refresh();
301
+ } catch (err) { toastError(err); }
302
+ }
303
+
304
+ function EditNewVersion(cl, refresh) {
305
+ const editor = el('div', { class: 'stack-3', style: { display: 'none' } });
306
+ const content = Textarea({ style: { minHeight: '220px' } });
307
+ content.value = cl.current_content || '';
308
+ const note = Input({ placeholder: '예: 지원동기 보강, 문장 다듬기' });
309
+
310
+ editor.append(
311
+ Field('내용', content),
312
+ Field('변경 메모 (선택)', note),
313
+ el('div', { class: 'flex gap-2' },
314
+ SubmitBtn('새 버전으로 저장', async () => {
315
+ if (!content.value.trim()) { content.focus(); throw new Error('내용을 입력해 주세요.'); }
316
+ await post(`/api/cover-letters/${cl.id}/versions`, {
317
+ content: content.value, note: note.value.trim() || undefined, source: 'edit',
318
+ });
319
+ toastOk('새 버전을 저장했어요.');
320
+ await refresh();
321
+ }, { icon: 'check' }),
322
+ Btn('취소', { variant: 'ghost', onClick: () => { editor.style.display = 'none'; toggle.style.display = ''; } }),
323
+ ),
324
+ );
325
+
326
+ const toggle = Btn('수정하여 새 버전 저장', {
327
+ variant: 'primary', icon: 'edit',
328
+ onClick: () => {
329
+ content.value = cl.current_content || '';
330
+ editor.style.display = '';
331
+ toggle.style.display = 'none';
332
+ content.focus();
333
+ },
334
+ });
335
+
336
+ return el('div', { class: 'stack-2' }, toggle, editor);
337
+ }
338
+
339
+ /* ============================================================ Tab B — 이력서·문서 */
340
+
341
+ 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]));
344
+ const wrap = el('div', { class: 'stack-3' });
345
+
346
+ if (!list.length) {
347
+ return mountInto(wrap, EmptyState({
348
+ iconName: 'file',
349
+ title: '저장된 문서가 없어요',
350
+ body: '이력서·경력기술서·포트폴리오 텍스트를 보관하세요.',
351
+ action: Btn('문서 추가', { icon: 'plus', variant: 'primary', onClick: () => openDocCreate(reload) }),
352
+ }));
353
+ }
354
+
355
+ for (const doc of list) wrap.append(DocRow(doc, kindLabels, m, reload));
356
+ return wrap;
357
+ }
358
+
359
+ function DocRow(doc, kindLabels, m, reload) {
360
+ const head = el('div', { class: 'flex between wrap gap-2 center' },
361
+ el('div', { class: 'flex gap-2 wrap center' },
362
+ el('span', { class: 'strong' }, doc.title),
363
+ el('span', { class: 'chip' }, kindLabels[doc.kind] || doc.kind),
364
+ doc.is_primary ? Badge('accent', '대표') : null,
365
+ ),
366
+ el('span', { class: 'muted text-sm' }, fmtRelative(doc.updated_at)),
367
+ );
368
+
369
+ return Card({
370
+ clickable: true,
371
+ onClick: () => openDocDetail(doc.id, kindLabels, m, reload),
372
+ body: [
373
+ head,
374
+ el('p', { class: 'text-secondary text-sm truncate', style: { margin: '8px 0 0', maxWidth: '100%' } }, firstLine(doc.content)),
375
+ (doc.tags && doc.tags.length)
376
+ ? el('div', { class: 'chips', style: { marginTop: '8px' } }, ...doc.tags.map((t) => el('span', { class: 'chip chip--accent' }, t)))
377
+ : null,
378
+ ],
379
+ });
380
+ }
381
+
382
+ async function openDocDetail(id, kindLabels, m, reload) {
383
+ let doc;
384
+ try {
385
+ doc = (await get(`/api/documents/${id}`)).document;
386
+ } catch (err) {
387
+ toastError(err);
388
+ return;
389
+ }
390
+
391
+ openModal({
392
+ title: doc.title,
393
+ size: 'lg',
394
+ body: el('div', { class: 'stack-3' },
395
+ el('div', { class: 'flex gap-2 wrap center' },
396
+ 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)}`),
400
+ ),
401
+ el('div', { class: 'doc-preview' }, doc.content || '내용이 없습니다.'),
402
+ ),
403
+ 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 });
408
+ if (!ok) return;
409
+ try { await del(`/api/documents/${doc.id}`); toastOk('문서를 삭제했어요.'); close(); await reload(); }
410
+ catch (err) { toastError(err); }
411
+ } }),
412
+ Btn('수정', { variant: 'primary', icon: 'edit', onClick: () => { close(); openDocEdit(doc, m, reload); } }),
413
+ ],
414
+ });
415
+ }
416
+
417
+ function openDocCreate(reload) {
418
+ meta().then((m) => docForm(null, m, reload)).catch(toastError);
419
+ }
420
+
421
+ function openDocEdit(doc, m, reload) {
422
+ docForm(doc, m, reload);
423
+ }
424
+
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' }));
427
+ const kind = Select(kinds);
428
+ const title = Input({ value: doc?.title || '', placeholder: '예: 2026 이력서', attrs: { maxlength: '200' } });
429
+ const content = Textarea({ placeholder: '이력서·경력기술서 본문을 붙여넣으세요.', style: { minHeight: '300px' } });
430
+ content.value = doc?.content || '';
431
+ const primary = el('input', { type: 'checkbox', checked: !!doc?.is_primary });
432
+
433
+ openModal({
434
+ title: doc ? '문서 수정' : '문서 추가',
435
+ size: 'lg',
436
+ 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', {}, '대표 문서로 지정'))),
441
+ ),
442
+ 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 };
449
+ if (doc) await put(`/api/documents/${doc.id}`, body);
450
+ else await post('/api/documents', { ...body, source: 'manual' });
451
+ toastOk(doc ? '문서를 수정했어요.' : '문서를 추가했어요.');
452
+ close();
453
+ await reload();
454
+ }),
455
+ ],
456
+ });
457
+ }
458
+
459
+ /* ------------------------------------------------------------------ helpers */
460
+
461
+ function firstLine(text) {
462
+ if (!text) return '';
463
+ const line = String(text).split('\n').find((l) => l.trim()) || '';
464
+ return line.length > 140 ? line.slice(0, 140) + '…' : line;
465
+ }
466
+
467
+ function mountInto(wrap, node) { wrap.append(node); return wrap; }