careermate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +256 -0
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/apps/mcp/src/index.ts +66 -0
- package/apps/web/DESIGN_GUIDE.md +105 -0
- package/apps/web/UI_CONTRACT.md +44 -0
- package/apps/web/public/app.js +118 -0
- package/apps/web/public/fonts/PretendardVariable.woff2 +0 -0
- package/apps/web/public/index.html +41 -0
- package/apps/web/public/lib.js +282 -0
- package/apps/web/public/pages/applications.js +98 -0
- package/apps/web/public/pages/documents.js +446 -0
- package/apps/web/public/pages/home.js +263 -0
- package/apps/web/public/pages/interview.js +230 -0
- package/apps/web/public/pages/jobs.js +494 -0
- package/apps/web/public/pages/profile.js +576 -0
- package/apps/web/public/pages/settings.js +233 -0
- package/apps/web/public/styles.css +426 -0
- package/apps/web/src/exports.ts +68 -0
- package/apps/web/src/http.ts +180 -0
- package/apps/web/src/index.ts +49 -0
- package/apps/web/src/info.ts +50 -0
- package/apps/web/src/routes.ts +350 -0
- package/apps/web/src/security.ts +102 -0
- package/apps/web/src/server.ts +141 -0
- package/apps/web/src/settings.ts +88 -0
- package/bin/careermate.mjs +74 -0
- package/dist/careermate.mcpb +0 -0
- package/dist/install-page/index.html +474 -0
- package/dist/install-page/style.css +391 -0
- package/dist/install-page/vercel.json +20 -0
- package/dist/mcp-smoke.err +3 -0
- package/dist/mcp.mjs +23704 -0
- package/dist/mcpb-stage/README.md +219 -0
- package/dist/mcpb-stage/dist/install-page/index.html +434 -0
- package/dist/mcpb-stage/dist/install-page/style.css +407 -0
- package/dist/mcpb-stage/dist/install-page/vercel.json +20 -0
- package/dist/mcpb-stage/dist/mcp.mjs +23704 -0
- package/dist/mcpb-stage/dist/public/app.js +118 -0
- package/dist/mcpb-stage/dist/public/fonts/PretendardVariable.woff2 +0 -0
- package/dist/mcpb-stage/dist/public/index.html +41 -0
- package/dist/mcpb-stage/dist/public/lib.js +282 -0
- package/dist/mcpb-stage/dist/public/pages/applications.js +98 -0
- package/dist/mcpb-stage/dist/public/pages/documents.js +446 -0
- package/dist/mcpb-stage/dist/public/pages/home.js +263 -0
- package/dist/mcpb-stage/dist/public/pages/interview.js +230 -0
- package/dist/mcpb-stage/dist/public/pages/jobs.js +494 -0
- package/dist/mcpb-stage/dist/public/pages/profile.js +576 -0
- package/dist/mcpb-stage/dist/public/pages/settings.js +233 -0
- package/dist/mcpb-stage/dist/public/styles.css +420 -0
- package/dist/mcpb-stage/dist/web.mjs +7240 -0
- package/dist/mcpb-stage/manifest.json +40 -0
- package/dist/public/app.js +118 -0
- package/dist/public/fonts/PretendardVariable.woff2 +0 -0
- package/dist/public/index.html +41 -0
- package/dist/public/lib.js +282 -0
- package/dist/public/pages/applications.js +98 -0
- package/dist/public/pages/documents.js +446 -0
- package/dist/public/pages/home.js +263 -0
- package/dist/public/pages/interview.js +230 -0
- package/dist/public/pages/jobs.js +494 -0
- package/dist/public/pages/profile.js +576 -0
- package/dist/public/pages/settings.js +233 -0
- package/dist/public/styles.css +426 -0
- package/dist/web.mjs +7240 -0
- package/docs/ARCHITECTURE.md +208 -0
- package/docs/CHANGES_V1.md +103 -0
- package/docs/DATA_MODEL.md +460 -0
- package/docs/DECISIONS.md +277 -0
- package/docs/DEMO.md +242 -0
- package/docs/INSTALL.md +148 -0
- package/docs/INSTALL_AND_USAGE.md +99 -0
- package/docs/MCP_TOOLS.md +233 -0
- package/docs/ROADMAP.md +134 -0
- package/docs/START_WORKFLOW.md +125 -0
- package/docs/SUPPORTED_AI_APPS.md +60 -0
- package/docs/TODO.md +57 -0
- package/docs/UX_NOTES.md +247 -0
- package/docs/WORKFLOWS.md +200 -0
- package/install-page/index.html +474 -0
- package/install-page/style.css +391 -0
- package/install-page/vercel.json +20 -0
- package/package.json +68 -0
- package/packages/core/src/context.ts +74 -0
- package/packages/core/src/index.ts +8 -0
- package/packages/core/src/onboarding.ts +81 -0
- package/packages/core/src/services.ts +146 -0
- package/packages/core/src/summary.ts +104 -0
- package/packages/db/src/connection.ts +46 -0
- package/packages/db/src/index.ts +22 -0
- package/packages/db/src/paths.ts +41 -0
- package/packages/db/src/repositories.ts +828 -0
- package/packages/db/src/runtime.ts +58 -0
- package/packages/db/src/schema.ts +189 -0
- package/packages/exporters/src/html.ts +113 -0
- package/packages/exporters/src/index.ts +364 -0
- package/packages/exporters/src/markdown.ts +178 -0
- package/packages/mcp-tools/src/bridge.ts +83 -0
- package/packages/mcp-tools/src/index.ts +8 -0
- package/packages/mcp-tools/src/result.ts +49 -0
- package/packages/mcp-tools/src/tools.ts +455 -0
- package/packages/parsers/src/html.ts +86 -0
- package/packages/parsers/src/index.ts +228 -0
- package/packages/parsers/src/keywords.ts +151 -0
- package/packages/prompts/src/humanize.ts +59 -0
- package/packages/prompts/src/index.ts +82 -0
- package/packages/prompts/src/install.ts +43 -0
- package/packages/prompts/src/onboarding.ts +35 -0
- package/packages/prompts/src/system.ts +53 -0
- package/packages/shared/src/enums.ts +103 -0
- package/packages/shared/src/index.ts +18 -0
- package/packages/shared/src/schemas.ts +398 -0
- package/packages/workflows/src/definitions.ts +107 -0
- package/packages/workflows/src/index.ts +39 -0
- package/scripts/build-dist.mjs +62 -0
- package/scripts/build-mcpb.mjs +70 -0
- package/scripts/doctor.ts +81 -0
- package/scripts/init.ts +342 -0
- package/scripts/mcp-probe.ts +55 -0
- package/scripts/migrate.ts +6 -0
- package/scripts/run.mjs +33 -0
- package/scripts/seed.ts +129 -0
- package/scripts/test.ts +117 -0
- package/scripts/ui-smoke.ts +73 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,446 @@
|
|
|
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, 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
|
+
let tab = 'cover'; // 'cover' | 'docs'
|
|
13
|
+
|
|
14
|
+
export async function render(ctx) {
|
|
15
|
+
const root = el('div', { class: 'stack-4' });
|
|
16
|
+
|
|
17
|
+
const panel = el('div', {});
|
|
18
|
+
const tabCover = el('div', { class: `tab${tab === 'cover' ? ' is-active' : ''}`, onClick: () => selectTab('cover') }, '자기소개서');
|
|
19
|
+
const tabDocs = el('div', { class: `tab${tab === 'docs' ? ' is-active' : ''}`, onClick: () => selectTab('docs') }, '이력서·문서');
|
|
20
|
+
root.append(el('div', { class: 'tabs' }, tabCover, tabDocs), panel);
|
|
21
|
+
|
|
22
|
+
let query = '';
|
|
23
|
+
function selectTab(next) {
|
|
24
|
+
tab = next;
|
|
25
|
+
query = '';
|
|
26
|
+
tabCover.classList.toggle('is-active', tab === 'cover');
|
|
27
|
+
tabDocs.classList.toggle('is-active', tab === 'docs');
|
|
28
|
+
setActions();
|
|
29
|
+
renderPanel();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Client-side filter over the rendered cards; survives panel re-renders.
|
|
33
|
+
function applyFilter() {
|
|
34
|
+
const q = query.trim().toLowerCase();
|
|
35
|
+
const cards = [...panel.querySelectorAll('.card')];
|
|
36
|
+
let shown = 0;
|
|
37
|
+
for (const c of cards) {
|
|
38
|
+
const hide = !!q && !c.textContent.toLowerCase().includes(q);
|
|
39
|
+
c.classList.toggle('hide', hide);
|
|
40
|
+
if (!hide) shown += 1;
|
|
41
|
+
}
|
|
42
|
+
const existing = panel.querySelector('.search-empty');
|
|
43
|
+
if (q && cards.length && shown === 0) {
|
|
44
|
+
if (!existing) panel.append(el('p', { class: 'search-empty muted', style: { textAlign: 'center', margin: '8px 0' } }, '검색 결과가 없어요.'));
|
|
45
|
+
} else if (existing) existing.remove();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function reload() {
|
|
49
|
+
await renderPanel();
|
|
50
|
+
await ctx.refreshNav();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function renderPanel() {
|
|
54
|
+
mount(panel, el('div', { class: 'muted text-sm' }, '불러오는 중…'));
|
|
55
|
+
try {
|
|
56
|
+
if (tab === 'cover') mount(panel, await CoverLettersTab(reload));
|
|
57
|
+
else mount(panel, await DocumentsTab(reload));
|
|
58
|
+
applyFilter();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
toastError(err);
|
|
61
|
+
mount(panel, el('div', { class: 'card' }, el('div', { class: 'card__body' },
|
|
62
|
+
el('p', { class: 'text-secondary' }, err instanceof Error ? err.message : String(err)))));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// topbar: search (filters the active tab) + the tab's create action
|
|
67
|
+
function setActions() {
|
|
68
|
+
const search = Input({ type: 'search', placeholder: tab === 'cover' ? '자기소개서 검색' : '문서 검색', value: query, attrs: { 'aria-label': '검색' } });
|
|
69
|
+
search.classList.add('input--inline');
|
|
70
|
+
search.addEventListener('input', () => { query = search.value; applyFilter(); });
|
|
71
|
+
const createBtn = tab === 'cover'
|
|
72
|
+
? Btn('새 자기소개서', { icon: 'plus', variant: 'primary', onClick: () => openCoverCreate(reload) })
|
|
73
|
+
: Btn('문서 추가', { icon: 'plus', variant: 'primary', onClick: () => openDocCreate(reload) });
|
|
74
|
+
ctx.setActions([search, createBtn]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
mount(ctx.view, root);
|
|
78
|
+
setActions();
|
|
79
|
+
await renderPanel();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ============================================================ Tab A — 자기소개서 */
|
|
83
|
+
|
|
84
|
+
async function CoverLettersTab(reload) {
|
|
85
|
+
const { cover_letters: list } = await get('/api/cover-letters');
|
|
86
|
+
const wrap = el('div', { class: 'stack-3' });
|
|
87
|
+
|
|
88
|
+
if (!list.length) {
|
|
89
|
+
return mountInto(wrap, EmptyState({
|
|
90
|
+
iconName: 'file',
|
|
91
|
+
title: '아직 자기소개서가 없어요',
|
|
92
|
+
body: '작성하거나 붙여넣어 버전과 함께 보관하세요.',
|
|
93
|
+
action: Btn('새 자기소개서', { icon: 'plus', variant: 'primary', onClick: () => openCoverCreate(reload) }),
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const cl of list) wrap.append(CoverRow(cl, reload));
|
|
98
|
+
return wrap;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function CoverRow(cl, reload) {
|
|
102
|
+
const metaRow = el('div', { class: 'flex gap-2 wrap', style: { marginTop: '4px' } },
|
|
103
|
+
el('span', { class: 'chip' }, `버전 ${cl.version_count}개`),
|
|
104
|
+
cl.is_primary ? Badge('final_passed', '대표') : null,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const actions = el('div', { class: 'flex gap-2 wrap' },
|
|
108
|
+
Btn('열기', { sm: true, icon: 'external', onClick: () => openCoverDetail(cl.id, reload) }),
|
|
109
|
+
Btn('복사', { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(cl.current_content || ''), disabled: !cl.current_content }),
|
|
110
|
+
Btn('MD', { sm: true, variant: 'ghost', icon: 'download', title: 'Markdown 내보내기', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=md`) }),
|
|
111
|
+
Btn('HTML', { sm: true, variant: 'ghost', icon: 'download', title: 'HTML 내보내기', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=html`) }),
|
|
112
|
+
cl.is_primary ? null : Btn('대표 지정', { sm: true, variant: 'ghost', icon: 'check', onClick: () => setPrimaryCover(cl.id, reload) }),
|
|
113
|
+
IconBtn('trash', { variant: 'danger', title: '삭제', onClick: () => removeCover(cl, reload) }),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return Card({
|
|
117
|
+
title: cl.title,
|
|
118
|
+
body: [
|
|
119
|
+
metaRow,
|
|
120
|
+
cl.current_content
|
|
121
|
+
? el('p', { class: 'text-secondary text-sm truncate', style: { margin: '8px 0 0', maxWidth: '100%' } }, firstLine(cl.current_content))
|
|
122
|
+
: el('p', { class: 'muted text-sm', style: { margin: '8px 0 0' } }, '아직 내용이 없습니다.'),
|
|
123
|
+
el('div', { class: 'divider' }),
|
|
124
|
+
actions,
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function setPrimaryCover(id, reload) {
|
|
130
|
+
try {
|
|
131
|
+
await put(`/api/cover-letters/${id}/primary`, {});
|
|
132
|
+
toastOk('대표 자기소개서로 지정했습니다.');
|
|
133
|
+
await reload();
|
|
134
|
+
} catch (err) { toastError(err); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function removeCover(cl, reload) {
|
|
138
|
+
const ok = await confirmDialog({
|
|
139
|
+
title: '자기소개서 삭제',
|
|
140
|
+
message: `"${cl.title}"을(를) 삭제할까요? 모든 버전 기록이 함께 삭제됩니다.`,
|
|
141
|
+
confirmLabel: '삭제', danger: true,
|
|
142
|
+
});
|
|
143
|
+
if (!ok) return;
|
|
144
|
+
try {
|
|
145
|
+
await del(`/api/cover-letters/${cl.id}`);
|
|
146
|
+
toastOk('자기소개서를 삭제했습니다.');
|
|
147
|
+
await reload();
|
|
148
|
+
} catch (err) { toastError(err); }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function openCoverCreate(reload) {
|
|
152
|
+
const title = Input({ placeholder: '예: 백엔드 개발자 자기소개서', attrs: { maxlength: '200' } });
|
|
153
|
+
const content = Textarea({ placeholder: '내용을 붙여넣으세요. 비워 두면 빈 자기소개서가 만들어집니다.', style: { minHeight: '200px' } });
|
|
154
|
+
const jobId = Input({ placeholder: '연결할 공고 ID (선택)' });
|
|
155
|
+
|
|
156
|
+
openModal({
|
|
157
|
+
title: '새 자기소개서',
|
|
158
|
+
size: 'lg',
|
|
159
|
+
body: el('div', { class: 'stack-3' },
|
|
160
|
+
Field('제목 (필수)', title),
|
|
161
|
+
Field('내용 (선택)', content, '내용을 입력하면 v1 버전으로 저장됩니다.'),
|
|
162
|
+
Field('공고 ID (선택)', jobId, '특정 공고에 연결하려면 입력하세요.'),
|
|
163
|
+
),
|
|
164
|
+
footer: (close) => [
|
|
165
|
+
Btn('취소', { onClick: close }),
|
|
166
|
+
Btn('자기소개서 저장', { variant: 'primary', onClick: async () => {
|
|
167
|
+
const t = title.value.trim();
|
|
168
|
+
if (!t) { toastError(new Error('제목을 입력해 주세요.')); title.focus(); return; }
|
|
169
|
+
try {
|
|
170
|
+
const body = { title: t, source: 'manual' };
|
|
171
|
+
if (content.value.trim()) body.content = content.value;
|
|
172
|
+
if (jobId.value.trim()) body.job_id = jobId.value.trim();
|
|
173
|
+
await post('/api/cover-letters', body);
|
|
174
|
+
toastOk('자기소개서를 추가했습니다.');
|
|
175
|
+
close();
|
|
176
|
+
await reload();
|
|
177
|
+
} catch (err) { toastError(err); }
|
|
178
|
+
} }),
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function openCoverDetail(id, reload) {
|
|
184
|
+
let data = (await get(`/api/cover-letters/${id}`)).cover_letter;
|
|
185
|
+
|
|
186
|
+
openModal({
|
|
187
|
+
title: data.title,
|
|
188
|
+
size: 'lg',
|
|
189
|
+
body: (close) => {
|
|
190
|
+
const container = el('div', {});
|
|
191
|
+
const draw = (cl) => mount(container, CoverDetailBody(cl, {
|
|
192
|
+
refresh: async () => {
|
|
193
|
+
data = (await get(`/api/cover-letters/${id}`)).cover_letter;
|
|
194
|
+
draw(data);
|
|
195
|
+
await reload();
|
|
196
|
+
},
|
|
197
|
+
onClose: close,
|
|
198
|
+
}));
|
|
199
|
+
draw(data);
|
|
200
|
+
return container;
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function CoverDetailBody(cl, { refresh }) {
|
|
206
|
+
const versions = (cl.versions || []).slice().sort((a, b) => b.version_no - a.version_no);
|
|
207
|
+
const wrap = el('div', { class: 'stack-4' });
|
|
208
|
+
|
|
209
|
+
// header meta + export
|
|
210
|
+
wrap.append(el('div', { class: 'flex between wrap gap-2' },
|
|
211
|
+
el('div', { class: 'flex gap-2 wrap' },
|
|
212
|
+
el('span', { class: 'chip' }, `버전 ${cl.version_count}개`),
|
|
213
|
+
cl.is_primary ? Badge('final_passed', '대표') : null,
|
|
214
|
+
),
|
|
215
|
+
el('div', { class: 'flex gap-2 wrap' },
|
|
216
|
+
Btn('MD 내보내기', { sm: true, variant: 'ghost', icon: 'download', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=md`) }),
|
|
217
|
+
Btn('HTML 내보내기', { sm: true, variant: 'ghost', icon: 'download', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=html`) }),
|
|
218
|
+
),
|
|
219
|
+
));
|
|
220
|
+
|
|
221
|
+
// preview — defaults to current content; can be swapped per-version
|
|
222
|
+
const previewLabel = el('div', { class: 'flex between center' },
|
|
223
|
+
el('h3', { style: { fontSize: '14px', margin: 0 } }, '현재 버전'),
|
|
224
|
+
Btn('복사', { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(preview.textContent || '') }),
|
|
225
|
+
);
|
|
226
|
+
const preview = el('div', { class: 'doc-preview' }, cl.current_content || '내용이 없습니다.');
|
|
227
|
+
|
|
228
|
+
const setPreview = (label, text) => {
|
|
229
|
+
previewLabel.firstChild.textContent = label;
|
|
230
|
+
mount(preview, text || '내용이 없습니다.');
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
wrap.append(el('div', { class: 'stack-2' }, previewLabel, preview));
|
|
234
|
+
|
|
235
|
+
// edit -> new version
|
|
236
|
+
wrap.append(EditNewVersion(cl, refresh));
|
|
237
|
+
|
|
238
|
+
// version history timeline
|
|
239
|
+
wrap.append(el('div', { class: 'stack-2' },
|
|
240
|
+
el('h3', { style: { fontSize: '14px', margin: 0 } }, '버전 기록'),
|
|
241
|
+
versions.length
|
|
242
|
+
? el('div', { class: 'timeline' }, ...versions.map((v) =>
|
|
243
|
+
VersionItem(cl, v, { isCurrent: v.id === cl.current_version_id, setPreview, refresh })))
|
|
244
|
+
: el('p', { class: 'muted', style: { margin: 0 } }, '아직 버전 기록이 없습니다.'),
|
|
245
|
+
));
|
|
246
|
+
|
|
247
|
+
return wrap;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function VersionItem(cl, v, { isCurrent, setPreview, refresh }) {
|
|
251
|
+
const head = el('div', { class: 'flex between wrap gap-2 center' },
|
|
252
|
+
el('div', { class: 'flex gap-2 center wrap' },
|
|
253
|
+
el('span', { class: 'strong' }, `v${v.version_no}`),
|
|
254
|
+
isCurrent ? Badge('applied', '현재 버전') : null,
|
|
255
|
+
el('span', { class: 'chip' }, sourceLabel(v.source)),
|
|
256
|
+
),
|
|
257
|
+
el('span', { class: 'muted text-sm' }, fmtRelative(v.created_at)),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const note = v.note ? el('div', { class: 'text-secondary text-sm', style: { marginTop: '4px' } }, v.note) : null;
|
|
261
|
+
|
|
262
|
+
const actions = el('div', { class: 'flex gap-2 wrap', style: { marginTop: '8px' } },
|
|
263
|
+
Btn('이 버전 보기', { sm: true, variant: 'ghost', icon: 'external', onClick: () => setPreview(`v${v.version_no} 보기`, v.content) }),
|
|
264
|
+
isCurrent ? null : Btn('현재 버전으로 지정', { sm: true, variant: 'ghost', icon: 'check', onClick: () => makeCurrent(cl.id, v.id, refresh) }),
|
|
265
|
+
Btn('복사', { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(v.content || '') }),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
return el('div', { class: `tl-item${isCurrent ? ' is-current' : ''}` },
|
|
269
|
+
el('div', { class: 'tl-item__rail' },
|
|
270
|
+
el('div', { class: 'tl-item__dot' }),
|
|
271
|
+
el('div', { class: 'tl-item__line' }),
|
|
272
|
+
),
|
|
273
|
+
el('div', { class: 'tl-item__body' }, head, note, actions),
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function makeCurrent(id, versionId, refresh) {
|
|
278
|
+
try {
|
|
279
|
+
await put(`/api/cover-letters/${id}/current-version`, { version_id: versionId });
|
|
280
|
+
toastOk('현재 버전을 변경했습니다.');
|
|
281
|
+
await refresh();
|
|
282
|
+
} catch (err) { toastError(err); }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function EditNewVersion(cl, refresh) {
|
|
286
|
+
const editor = el('div', { class: 'stack-3', style: { display: 'none' } });
|
|
287
|
+
const content = Textarea({ style: { minHeight: '220px' } });
|
|
288
|
+
content.value = cl.current_content || '';
|
|
289
|
+
const note = Input({ placeholder: '예: 지원동기 보강, 문장 다듬기' });
|
|
290
|
+
|
|
291
|
+
editor.append(
|
|
292
|
+
Field('내용', content),
|
|
293
|
+
Field('변경 메모 (선택)', note),
|
|
294
|
+
el('div', { class: 'flex gap-2' },
|
|
295
|
+
Btn('새 버전으로 저장', { variant: 'primary', icon: 'check', onClick: async () => {
|
|
296
|
+
if (!content.value.trim()) { toastError(new Error('내용을 입력해 주세요.')); content.focus(); return; }
|
|
297
|
+
try {
|
|
298
|
+
await post(`/api/cover-letters/${cl.id}/versions`, {
|
|
299
|
+
content: content.value, note: note.value.trim() || undefined, source: 'edit',
|
|
300
|
+
});
|
|
301
|
+
toastOk('새 버전을 저장했습니다.');
|
|
302
|
+
await refresh();
|
|
303
|
+
} catch (err) { toastError(err); }
|
|
304
|
+
} }),
|
|
305
|
+
Btn('취소', { variant: 'ghost', onClick: () => { editor.style.display = 'none'; toggle.style.display = ''; } }),
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const toggle = Btn('수정하여 새 버전 저장', {
|
|
310
|
+
variant: 'primary', icon: 'edit',
|
|
311
|
+
onClick: () => {
|
|
312
|
+
content.value = cl.current_content || '';
|
|
313
|
+
editor.style.display = '';
|
|
314
|
+
toggle.style.display = 'none';
|
|
315
|
+
content.focus();
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
return el('div', { class: 'stack-2' }, toggle, editor);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* ============================================================ Tab B — 이력서·문서 */
|
|
323
|
+
|
|
324
|
+
async function DocumentsTab(reload) {
|
|
325
|
+
const [{ documents: list }, m] = await Promise.all([get('/api/documents'), meta()]);
|
|
326
|
+
const kindLabels = Object.fromEntries((m.document_kinds || []).map((k) => [k.value, k.label]));
|
|
327
|
+
const wrap = el('div', { class: 'stack-3' });
|
|
328
|
+
|
|
329
|
+
if (!list.length) {
|
|
330
|
+
return mountInto(wrap, EmptyState({
|
|
331
|
+
iconName: 'file',
|
|
332
|
+
title: '저장된 문서가 없어요',
|
|
333
|
+
body: '이력서·경력기술서·포트폴리오 텍스트를 보관하세요.',
|
|
334
|
+
action: Btn('문서 추가', { icon: 'plus', variant: 'primary', onClick: () => openDocCreate(reload) }),
|
|
335
|
+
}));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const doc of list) wrap.append(DocRow(doc, kindLabels, m, reload));
|
|
339
|
+
return wrap;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function DocRow(doc, kindLabels, m, reload) {
|
|
343
|
+
const head = el('div', { class: 'flex between wrap gap-2 center' },
|
|
344
|
+
el('div', { class: 'flex gap-2 wrap center' },
|
|
345
|
+
el('span', { class: 'strong' }, doc.title),
|
|
346
|
+
el('span', { class: 'chip' }, kindLabels[doc.kind] || doc.kind),
|
|
347
|
+
doc.is_primary ? Badge('final_passed', '대표') : null,
|
|
348
|
+
),
|
|
349
|
+
el('span', { class: 'muted text-sm' }, fmtRelative(doc.updated_at)),
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
return Card({
|
|
353
|
+
clickable: true,
|
|
354
|
+
onClick: () => openDocDetail(doc.id, kindLabels, m, reload),
|
|
355
|
+
body: [
|
|
356
|
+
head,
|
|
357
|
+
el('p', { class: 'text-secondary text-sm truncate', style: { margin: '8px 0 0', maxWidth: '100%' } }, firstLine(doc.content)),
|
|
358
|
+
(doc.tags && doc.tags.length)
|
|
359
|
+
? el('div', { class: 'chips', style: { marginTop: '8px' } }, ...doc.tags.map((t) => el('span', { class: 'chip chip--accent' }, t)))
|
|
360
|
+
: null,
|
|
361
|
+
],
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function openDocDetail(id, kindLabels, m, reload) {
|
|
366
|
+
const doc = (await get(`/api/documents/${id}`)).document;
|
|
367
|
+
|
|
368
|
+
openModal({
|
|
369
|
+
title: doc.title,
|
|
370
|
+
size: 'lg',
|
|
371
|
+
body: el('div', { class: 'stack-3' },
|
|
372
|
+
el('div', { class: 'flex gap-2 wrap center' },
|
|
373
|
+
el('span', { class: 'chip' }, kindLabels[doc.kind] || doc.kind),
|
|
374
|
+
doc.is_primary ? Badge('final_passed', '대표') : null,
|
|
375
|
+
el('span', { class: 'chip' }, sourceLabel(doc.source)),
|
|
376
|
+
el('span', { class: 'muted text-sm' }, `수정 ${fmtRelative(doc.updated_at)}`),
|
|
377
|
+
),
|
|
378
|
+
el('div', { class: 'doc-preview' }, doc.content || '내용이 없습니다.'),
|
|
379
|
+
),
|
|
380
|
+
footer: (close) => [
|
|
381
|
+
Btn('복사', { variant: 'ghost', icon: 'copy', onClick: () => copyText(doc.content || '') }),
|
|
382
|
+
Btn('내보내기', { variant: 'ghost', icon: 'download', title: 'Markdown 내보내기', onClick: () => downloadUrl(`/api/export/document/${doc.id}?format=md`) }),
|
|
383
|
+
Btn('삭제', { variant: 'danger', icon: 'trash', onClick: async () => {
|
|
384
|
+
const ok = await confirmDialog({ title: '문서 삭제', message: `"${doc.title}"을(를) 삭제할까요?`, confirmLabel: '삭제', danger: true });
|
|
385
|
+
if (!ok) return;
|
|
386
|
+
try { await del(`/api/documents/${doc.id}`); toastOk('문서를 삭제했습니다.'); close(); await reload(); }
|
|
387
|
+
catch (err) { toastError(err); }
|
|
388
|
+
} }),
|
|
389
|
+
Btn('수정', { variant: 'primary', icon: 'edit', onClick: () => { close(); openDocEdit(doc, m, reload); } }),
|
|
390
|
+
],
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function openDocCreate(reload) {
|
|
395
|
+
meta().then((m) => docForm(null, m, reload));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function openDocEdit(doc, m, reload) {
|
|
399
|
+
docForm(doc, m, reload);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function docForm(doc, m, reload) {
|
|
403
|
+
const kinds = (m.document_kinds || []).map((k) => ({ value: k.value, label: k.label, selected: doc ? doc.kind === k.value : k.value === 'resume' }));
|
|
404
|
+
const kind = Select(kinds);
|
|
405
|
+
const title = Input({ value: doc?.title || '', placeholder: '예: 2026 이력서', attrs: { maxlength: '200' } });
|
|
406
|
+
const content = Textarea({ placeholder: '이력서·경력기술서 본문을 붙여넣으세요.', style: { minHeight: '300px' } });
|
|
407
|
+
content.value = doc?.content || '';
|
|
408
|
+
const primary = el('input', { type: 'checkbox', checked: !!doc?.is_primary });
|
|
409
|
+
|
|
410
|
+
openModal({
|
|
411
|
+
title: doc ? '문서 수정' : '문서 추가',
|
|
412
|
+
size: 'lg',
|
|
413
|
+
body: el('div', { class: 'stack-3' },
|
|
414
|
+
Field('종류', kind),
|
|
415
|
+
Field('제목 (필수)', title),
|
|
416
|
+
Field('내용 (필수)', content),
|
|
417
|
+
Field(null, el('label', { class: 'flex gap-2 center', style: { cursor: 'pointer' } }, primary, el('span', {}, '대표 문서로 지정'))),
|
|
418
|
+
),
|
|
419
|
+
footer: (close) => [
|
|
420
|
+
Btn('취소', { onClick: close }),
|
|
421
|
+
Btn('문서 저장', { variant: 'primary', onClick: async () => {
|
|
422
|
+
const t = title.value.trim();
|
|
423
|
+
if (!t) { toastError(new Error('제목을 입력해 주세요.')); title.focus(); return; }
|
|
424
|
+
if (!content.value.trim()) { toastError(new Error('내용을 입력해 주세요.')); content.focus(); return; }
|
|
425
|
+
const body = { kind: kind.value, title: t, content: content.value, is_primary: primary.checked };
|
|
426
|
+
try {
|
|
427
|
+
if (doc) await put(`/api/documents/${doc.id}`, body);
|
|
428
|
+
else await post('/api/documents', { ...body, source: 'manual' });
|
|
429
|
+
toastOk(doc ? '문서를 수정했습니다.' : '문서를 추가했습니다.');
|
|
430
|
+
close();
|
|
431
|
+
await reload();
|
|
432
|
+
} catch (err) { toastError(err); }
|
|
433
|
+
} }),
|
|
434
|
+
],
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/* ------------------------------------------------------------------ helpers */
|
|
439
|
+
|
|
440
|
+
function firstLine(text) {
|
|
441
|
+
if (!text) return '';
|
|
442
|
+
const line = String(text).split('\n').find((l) => l.trim()) || '';
|
|
443
|
+
return line.length > 140 ? line.slice(0, 140) + '…' : line;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function mountInto(wrap, node) { wrap.append(node); return wrap; }
|