careermate 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +11 -7
- package/INSTALL.md +18 -16
- package/README.md +28 -26
- package/THIRD_PARTY_NOTICES.md +2 -3
- package/bin/careermate.mjs +8 -1
- package/dist/career-os/README.md +8 -3
- package/dist/career-os/eop/career-description.md +136 -0
- package/dist/career-os/eop/cover-letter.md +7 -5
- package/dist/career-os/eop/fit-analysis.md +15 -5
- package/dist/career-os/eop/interview-prep.md +209 -21
- package/dist/career-os/eop/job-analysis.md +5 -3
- package/dist/career-os/eop/profile-extraction.md +5 -4
- package/dist/career-os/knowledge/cover-letter.md +4 -4
- package/dist/career-os/knowledge/fit-matching.md +8 -8
- package/dist/career-os/knowledge/interview-behavioral.md +76 -8
- package/dist/career-os/knowledge/interview-technical.md +106 -4
- package/dist/career-os/knowledge/linkedin-profile.md +1 -1
- package/dist/career-os/knowledge/networking-referrals.md +1 -1
- package/dist/career-os/knowledge/offer-evaluation-decision.md +10 -10
- package/dist/career-os/knowledge/portfolio.md +3 -3
- package/dist/career-os/knowledge/verifiers/ats-compat.md +8 -4
- package/dist/career-os/knowledge/verifiers/human-voice.md +2 -2
- package/dist/career-os/knowledge/verifiers/recency-staleness.md +1 -1
- package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
- package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -1
- package/dist/doctor.mjs +1019 -127
- package/dist/mcp.mjs +1036 -126
- package/dist/migrate.mjs +52 -1
- package/dist/public/app.js +42 -16
- package/dist/public/i18n/en/applications.js +23 -0
- package/dist/public/i18n/en/common.js +22 -0
- package/dist/public/i18n/en/documents.js +133 -0
- package/dist/public/i18n/en/home.js +75 -0
- package/dist/public/i18n/en/interview.js +69 -0
- package/dist/public/i18n/en/jobs.js +152 -0
- package/dist/public/i18n/en/nav.js +18 -0
- package/dist/public/i18n/en/profile.js +201 -0
- package/dist/public/i18n/en/settings.js +127 -0
- package/dist/public/i18n/en/status.js +22 -0
- package/dist/public/i18n/en.js +17 -0
- package/dist/public/i18n/ko/applications.js +23 -0
- package/dist/public/i18n/ko/common.js +22 -0
- package/dist/public/i18n/ko/documents.js +133 -0
- package/dist/public/i18n/ko/home.js +63 -0
- package/dist/public/i18n/ko/interview.js +69 -0
- package/dist/public/i18n/ko/jobs.js +152 -0
- package/dist/public/i18n/ko/nav.js +18 -0
- package/dist/public/i18n/ko/profile.js +201 -0
- package/dist/public/i18n/ko/settings.js +127 -0
- package/dist/public/i18n/ko/status.js +23 -0
- package/dist/public/i18n/ko.js +18 -0
- package/dist/public/i18n.js +55 -0
- package/dist/public/index.html +1 -0
- package/dist/public/lib.js +25 -17
- package/dist/public/pages/applications.js +17 -21
- package/dist/public/pages/documents.js +153 -107
- package/dist/public/pages/home.js +88 -53
- package/dist/public/pages/interview.js +105 -51
- package/dist/public/pages/jobs.js +330 -106
- package/dist/public/pages/profile.js +157 -152
- package/dist/public/pages/settings.js +270 -72
- package/dist/public/styles.css +38 -3
- package/dist/web.mjs +859 -103
- package/docs/FAQ.md +26 -5
- package/docs/MCP_TOOLS.md +26 -12
- package/docs/START_WORKFLOW.md +2 -2
- package/docs/SUPPORTED_AI_APPS.md +13 -0
- package/docs/WORKFLOWS.md +80 -23
- package/docs/career-os/README.md +8 -3
- package/docs/career-os/eop/career-description.md +136 -0
- package/docs/career-os/eop/cover-letter.md +7 -5
- package/docs/career-os/eop/fit-analysis.md +15 -5
- package/docs/career-os/eop/interview-prep.md +209 -21
- package/docs/career-os/eop/job-analysis.md +5 -3
- package/docs/career-os/eop/profile-extraction.md +5 -4
- package/docs/career-os/knowledge/cover-letter.md +4 -4
- package/docs/career-os/knowledge/fit-matching.md +8 -8
- package/docs/career-os/knowledge/interview-behavioral.md +76 -8
- package/docs/career-os/knowledge/interview-technical.md +106 -4
- package/docs/career-os/knowledge/linkedin-profile.md +1 -1
- package/docs/career-os/knowledge/networking-referrals.md +1 -1
- package/docs/career-os/knowledge/offer-evaluation-decision.md +10 -10
- package/docs/career-os/knowledge/portfolio.md +3 -3
- package/docs/career-os/knowledge/verifiers/ats-compat.md +8 -4
- package/docs/career-os/knowledge/verifiers/human-voice.md +2 -2
- package/docs/career-os/knowledge/verifiers/recency-staleness.md +1 -1
- package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
- package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -1
- package/package.json +2 -2
- package/packages/core/src/onboarding.ts +2 -1
- package/packages/core/src/services.ts +138 -21
- package/packages/core/src/summary.ts +59 -1
- package/packages/core/src/verify/lint.ts +4 -0
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/repositories.ts +0 -0
- package/packages/db/src/schema.ts +17 -0
- package/packages/exporters/src/index.ts +28 -17
- package/packages/knowledge/src/index.ts +46 -7
- package/packages/mcp-tools/src/bridge.ts +194 -13
- package/packages/mcp-tools/src/tools.ts +390 -38
- package/packages/mcp-tools/src/update.ts +31 -16
- package/packages/parsers/src/documents.ts +12 -1
- package/packages/prompts/src/humanize.ts +46 -0
- package/packages/prompts/src/index.ts +5 -1
- package/packages/prompts/src/install.ts +4 -4
- package/packages/prompts/src/onboarding.ts +4 -4
- package/packages/prompts/src/system.ts +17 -8
- package/packages/shared/src/enums.ts +14 -4
- package/packages/shared/src/schemas.ts +41 -0
- package/packages/workflows/src/definitions.ts +47 -10
- package/scripts/init.ts +284 -13
- package/dist/site/analytics.js +0 -97
- package/dist/site/favicon.svg +0 -1
- package/dist/site/fonts/PretendardVariable.woff2 +0 -0
- package/dist/site/icon-180.png +0 -0
- package/dist/site/icon-512.png +0 -0
- package/dist/site/index.html +0 -565
- package/dist/site/landing.js +0 -54
- package/dist/site/llms-install.txt +0 -218
- package/dist/site/robots.txt +0 -4
- package/dist/site/sitemap.xml +0 -21
- package/dist/site/start.html +0 -353
- package/dist/site/style.css +0 -559
- package/dist/site/vercel.json +0 -20
|
@@ -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
|
-
|
|
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
|
|
23
|
-
|
|
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' } },
|
|
51
|
-
Btn('
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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('
|
|
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' },
|
|
112
|
-
cl.is_primary ? Badge('accent', '
|
|
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('
|
|
117
|
-
Btn('
|
|
118
|
-
Btn('
|
|
119
|
-
Btn('
|
|
120
|
-
cl.is_primary ? null : Btn('
|
|
121
|
-
IconBtn('trash', { variant: 'danger', title: '
|
|
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:
|
|
149
|
-
confirmLabel: '
|
|
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: '
|
|
164
|
-
const content = Textarea({ placeholder: '
|
|
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: '
|
|
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('
|
|
175
|
-
Field('
|
|
176
|
-
Field('
|
|
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('
|
|
181
|
-
SubmitBtn('
|
|
182
|
-
const
|
|
183
|
-
if (!
|
|
184
|
-
const body = { title:
|
|
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' },
|
|
232
|
-
cl.is_primary ? Badge('accent', '
|
|
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('
|
|
236
|
-
Btn('
|
|
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('
|
|
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' },
|
|
273
|
-
isCurrent ? Badge('applied', '
|
|
274
|
-
el('span', { class: 'chip' },
|
|
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('
|
|
283
|
-
isCurrent ? null : Btn('
|
|
284
|
-
Btn('
|
|
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('
|
|
312
|
-
Field('
|
|
334
|
+
Field(t('documents.editVersion.fieldContent'), content),
|
|
335
|
+
Field(t('documents.editVersion.fieldNote'), note),
|
|
313
336
|
el('div', { class: 'flex gap-2' },
|
|
314
|
-
SubmitBtn('
|
|
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('
|
|
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
|
|
343
|
-
const
|
|
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('
|
|
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', '
|
|
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', '
|
|
398
|
-
el('span', { class: 'chip' },
|
|
399
|
-
el('span', { class: 'muted text-sm' },
|
|
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('
|
|
405
|
-
Btn('
|
|
406
|
-
Btn('
|
|
407
|
-
|
|
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('
|
|
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('
|
|
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
|
|
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: '
|
|
429
|
-
const content = Textarea({ placeholder: '
|
|
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('
|
|
438
|
-
Field('
|
|
439
|
-
Field('
|
|
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('
|
|
444
|
-
SubmitBtn('
|
|
445
|
-
const
|
|
446
|
-
if (!
|
|
447
|
-
if (!content.value.trim()) { content.focus(); throw new Error('
|
|
448
|
-
const body = { kind: kind.value, title:
|
|
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
|
}),
|