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
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import {
|
|
3
3
|
el, get, post, put, del, icon, Card, Badge, Btn, IconBtn, SubmitBtn, EmptyState, Chips,
|
|
4
4
|
Field, Input, Textarea, openModal, closeModal, confirmDialog,
|
|
5
|
-
toastOk, toastError, fmtDate, mount, linesToArray, csvToArray,
|
|
5
|
+
toastOk, toastError, fmtDate, mount, linesToArray, csvToArray, downloadUrl,
|
|
6
6
|
} from '/lib.js';
|
|
7
|
+
import { t } from '/i18n.js';
|
|
7
8
|
|
|
8
9
|
/* ----------------------------------------------------------- small helpers */
|
|
9
10
|
|
|
@@ -12,9 +13,9 @@ function val(node) { return node ? node.value : ''; }
|
|
|
12
13
|
// Render a "YYYY-MM ~ YYYY-MM / 재직중" period label from a record.
|
|
13
14
|
function periodLabel(r) {
|
|
14
15
|
const start = r.start_date || '';
|
|
15
|
-
if (r.is_current) return start ?
|
|
16
|
+
if (r.is_current) return start ? t('profile.period.startToPresent', { start }) : t('profile.period.present');
|
|
16
17
|
const end = r.end_date || '';
|
|
17
|
-
if (start && end) return
|
|
18
|
+
if (start && end) return t('profile.period.startToEnd', { start, end });
|
|
18
19
|
return start || end || '';
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -31,7 +32,7 @@ function kv(rows) {
|
|
|
31
32
|
/* ------------------------------------------------------------------ render */
|
|
32
33
|
|
|
33
34
|
export async function render(ctx) {
|
|
34
|
-
ctx.setTitle?.('
|
|
35
|
+
ctx.setTitle?.(t('profile.title'));
|
|
35
36
|
const [{ profile }, { experiences }, { projects }, { skills }, onboarding] = await Promise.all([
|
|
36
37
|
get('/api/profile'),
|
|
37
38
|
get('/api/experiences'),
|
|
@@ -63,8 +64,12 @@ function CompletenessCard(o) {
|
|
|
63
64
|
const pct = o?.profile_completeness ?? 0;
|
|
64
65
|
const steps = (o?.next_steps || []).slice(0, 3);
|
|
65
66
|
return Card({
|
|
66
|
-
title: '
|
|
67
|
-
sub:
|
|
67
|
+
title: t('profile.completeness.title'),
|
|
68
|
+
sub: t('profile.completeness.percent', { pct }),
|
|
69
|
+
actions: [
|
|
70
|
+
Btn(t('profile.export.md'), { sm: true, variant: 'ghost', icon: 'download', title: t('profile.export.mdTitle'), onClick: () => downloadUrl('/api/export/profile?format=md') }),
|
|
71
|
+
Btn(t('profile.export.html'), { sm: true, variant: 'ghost', icon: 'download', title: t('profile.export.htmlTitle'), onClick: () => downloadUrl('/api/export/profile?format=html') }),
|
|
72
|
+
],
|
|
68
73
|
body: [
|
|
69
74
|
el('div', { class: 'progress', style: { marginBottom: steps.length ? '12px' : '0' } },
|
|
70
75
|
el('div', { class: 'progress__bar', style: { width: `${pct}%` } })),
|
|
@@ -85,14 +90,14 @@ function BasicInfoCard(p, reload) {
|
|
|
85
90
|
|
|
86
91
|
const body = hasAny ? [
|
|
87
92
|
kv([
|
|
88
|
-
['
|
|
89
|
-
['
|
|
90
|
-
['
|
|
91
|
-
['
|
|
92
|
-
['
|
|
93
|
+
[t('profile.basic.kv.name'), p.name],
|
|
94
|
+
[t('profile.basic.kv.email'), p.email],
|
|
95
|
+
[t('profile.basic.kv.phone'), p.phone],
|
|
96
|
+
[t('profile.basic.kv.location'), p.location],
|
|
97
|
+
[t('profile.basic.kv.headline'), p.headline],
|
|
93
98
|
]),
|
|
94
99
|
p.summary ? el('div', { class: 'mt-3' },
|
|
95
|
-
el('div', { class: 'muted text-sm mb-2' }, '
|
|
100
|
+
el('div', { class: 'muted text-sm mb-2' }, t('profile.basic.summaryLabel')),
|
|
96
101
|
el('div', { class: 'doc-preview' }, p.summary)) : null,
|
|
97
102
|
(p.links || []).length ? el('div', { class: 'mt-3 flex wrap gap-2' },
|
|
98
103
|
...p.links.map((l) => el('a', {
|
|
@@ -100,45 +105,45 @@ function BasicInfoCard(p, reload) {
|
|
|
100
105
|
}, icon('link'), l.label || l.url))) : null,
|
|
101
106
|
] : EmptyState({
|
|
102
107
|
iconName: 'user',
|
|
103
|
-
title: '
|
|
104
|
-
body: '
|
|
105
|
-
action: Btn('
|
|
108
|
+
title: t('profile.basic.empty.title'),
|
|
109
|
+
body: t('profile.basic.empty.body'),
|
|
110
|
+
action: Btn(t('profile.basic.empty.action'), { icon: 'edit', variant: 'primary', onClick: () => editBasic(p, reload) }),
|
|
106
111
|
});
|
|
107
112
|
|
|
108
113
|
return Card({
|
|
109
|
-
title: '
|
|
110
|
-
actions: hasAny ? Btn('
|
|
114
|
+
title: t('profile.basic.title'),
|
|
115
|
+
actions: hasAny ? Btn(t('profile.basic.edit'), { icon: 'edit', sm: true, variant: 'ghost', onClick: () => editBasic(p, reload) }) : null,
|
|
111
116
|
body,
|
|
112
117
|
});
|
|
113
118
|
}
|
|
114
119
|
|
|
115
120
|
function editBasic(p, reload) {
|
|
116
|
-
const name = Input({ value: p.name || '', placeholder: '
|
|
117
|
-
const email = Input({ value: p.email || '', type: 'email', placeholder: '
|
|
118
|
-
const phone = Input({ value: p.phone || '', placeholder: '
|
|
119
|
-
const location = Input({ value: p.location || '', placeholder: '
|
|
120
|
-
const headline = Input({ value: p.headline || '', placeholder: '
|
|
121
|
-
const summary = Textarea({ value: p.summary || '', attrs: { rows: '5' }, placeholder: '
|
|
122
|
-
// links as one "
|
|
121
|
+
const name = Input({ value: p.name || '', placeholder: t('profile.basic.ph.name') });
|
|
122
|
+
const email = Input({ value: p.email || '', type: 'email', placeholder: t('profile.basic.ph.email') });
|
|
123
|
+
const phone = Input({ value: p.phone || '', placeholder: t('profile.basic.ph.phone') });
|
|
124
|
+
const location = Input({ value: p.location || '', placeholder: t('profile.basic.ph.location') });
|
|
125
|
+
const headline = Input({ value: p.headline || '', placeholder: t('profile.basic.ph.headline') });
|
|
126
|
+
const summary = Textarea({ value: p.summary || '', attrs: { rows: '5' }, placeholder: t('profile.basic.ph.summary') });
|
|
127
|
+
// links as one "label | URL" per line
|
|
123
128
|
const linksTa = Textarea({
|
|
124
129
|
value: (p.links || []).map((l) => `${l.label} | ${l.url}`).join('\n'),
|
|
125
|
-
attrs: { rows: '3' }, placeholder: '
|
|
130
|
+
attrs: { rows: '3' }, placeholder: t('profile.basic.ph.links'),
|
|
126
131
|
});
|
|
127
132
|
|
|
128
133
|
openModal({
|
|
129
|
-
title: '
|
|
134
|
+
title: t('profile.basic.modal.title'),
|
|
130
135
|
size: 'lg',
|
|
131
136
|
body: el('div', {},
|
|
132
|
-
Field('
|
|
133
|
-
el('div', { class: 'grid grid--2' }, Field('
|
|
134
|
-
Field('
|
|
135
|
-
Field('
|
|
136
|
-
Field('
|
|
137
|
-
Field('
|
|
137
|
+
Field(t('profile.basic.field.name'), name),
|
|
138
|
+
el('div', { class: 'grid grid--2' }, Field(t('profile.basic.field.email'), email), Field(t('profile.basic.field.phone'), phone)),
|
|
139
|
+
Field(t('profile.basic.field.location'), location),
|
|
140
|
+
Field(t('profile.basic.field.headline'), headline, t('profile.basic.field.headlineHint')),
|
|
141
|
+
Field(t('profile.basic.field.summary'), summary),
|
|
142
|
+
Field(t('profile.basic.field.links'), linksTa, t('profile.basic.field.linksHint')),
|
|
138
143
|
),
|
|
139
144
|
footer: (close) => [
|
|
140
|
-
Btn('
|
|
141
|
-
SubmitBtn('
|
|
145
|
+
Btn(t('profile.basic.cancel'), { onClick: close }),
|
|
146
|
+
SubmitBtn(t('profile.basic.save'), async () => {
|
|
142
147
|
const links = linesToArray(linksTa.value).map((line) => {
|
|
143
148
|
const i = line.indexOf('|');
|
|
144
149
|
const label = (i >= 0 ? line.slice(0, i) : line).trim();
|
|
@@ -150,7 +155,7 @@ function editBasic(p, reload) {
|
|
|
150
155
|
location: val(location).trim(), headline: val(headline).trim(),
|
|
151
156
|
summary: val(summary).trim(), links,
|
|
152
157
|
});
|
|
153
|
-
toastOk('
|
|
158
|
+
toastOk(t('profile.basic.saved'));
|
|
154
159
|
close();
|
|
155
160
|
await reload();
|
|
156
161
|
}),
|
|
@@ -166,42 +171,42 @@ function DesiredCard(p, reload) {
|
|
|
166
171
|
|
|
167
172
|
const body = hasAny ? [
|
|
168
173
|
roles.length ? el('div', {},
|
|
169
|
-
el('div', { class: 'muted text-sm mb-2' }, '
|
|
174
|
+
el('div', { class: 'muted text-sm mb-2' }, t('profile.desired.rolesLabel')),
|
|
170
175
|
Chips(roles, { accent: true })) : null,
|
|
171
176
|
p.desired_conditions ? el('div', { class: roles.length ? 'mt-3' : '' },
|
|
172
|
-
el('div', { class: 'muted text-sm mb-2' }, '
|
|
177
|
+
el('div', { class: 'muted text-sm mb-2' }, t('profile.desired.conditionsLabel')),
|
|
173
178
|
el('div', { class: 'doc-preview' }, p.desired_conditions)) : null,
|
|
174
179
|
] : EmptyState({
|
|
175
180
|
iconName: 'target',
|
|
176
|
-
title: '
|
|
177
|
-
body: '
|
|
178
|
-
action: Btn('
|
|
181
|
+
title: t('profile.desired.empty.title'),
|
|
182
|
+
body: t('profile.desired.empty.body'),
|
|
183
|
+
action: Btn(t('profile.desired.empty.action'), { icon: 'edit', variant: 'primary', onClick: () => editDesired(p, reload) }),
|
|
179
184
|
});
|
|
180
185
|
|
|
181
186
|
return Card({
|
|
182
|
-
title: '
|
|
183
|
-
actions: hasAny ? Btn('
|
|
187
|
+
title: t('profile.desired.title'),
|
|
188
|
+
actions: hasAny ? Btn(t('profile.desired.edit'), { icon: 'edit', sm: true, variant: 'ghost', onClick: () => editDesired(p, reload) }) : null,
|
|
184
189
|
body,
|
|
185
190
|
});
|
|
186
191
|
}
|
|
187
192
|
|
|
188
193
|
function editDesired(p, reload) {
|
|
189
|
-
const roles = Input({ value: (p.desired_roles || []).join(', '), placeholder: '
|
|
190
|
-
const conditions = Textarea({ value: p.desired_conditions || '', attrs: { rows: '4' }, placeholder: '
|
|
194
|
+
const roles = Input({ value: (p.desired_roles || []).join(', '), placeholder: t('profile.desired.ph.roles') });
|
|
195
|
+
const conditions = Textarea({ value: p.desired_conditions || '', attrs: { rows: '4' }, placeholder: t('profile.desired.ph.conditions') });
|
|
191
196
|
openModal({
|
|
192
|
-
title: '
|
|
197
|
+
title: t('profile.desired.modal.title'),
|
|
193
198
|
body: el('div', {},
|
|
194
|
-
Field('
|
|
195
|
-
Field('
|
|
199
|
+
Field(t('profile.desired.field.roles'), roles, t('profile.desired.field.rolesHint')),
|
|
200
|
+
Field(t('profile.desired.field.conditions'), conditions, t('profile.desired.field.conditionsHint')),
|
|
196
201
|
),
|
|
197
202
|
footer: (close) => [
|
|
198
|
-
Btn('
|
|
199
|
-
SubmitBtn('
|
|
203
|
+
Btn(t('profile.desired.cancel'), { onClick: close }),
|
|
204
|
+
SubmitBtn(t('profile.desired.save'), async () => {
|
|
200
205
|
await put('/api/profile', {
|
|
201
206
|
desired_roles: csvToArray(roles.value),
|
|
202
207
|
desired_conditions: val(conditions).trim(),
|
|
203
208
|
});
|
|
204
|
-
toastOk('
|
|
209
|
+
toastOk(t('profile.desired.saved'));
|
|
205
210
|
close();
|
|
206
211
|
await reload();
|
|
207
212
|
}),
|
|
@@ -219,41 +224,41 @@ function WritingCard(p, reload) {
|
|
|
219
224
|
!hasAny ? el('div', { class: 'callout', style: { marginBottom: '12px' } },
|
|
220
225
|
icon('sparkle'),
|
|
221
226
|
el('div', {},
|
|
222
|
-
el('div', { class: 'callout__title' }, '
|
|
223
|
-
el('div', { class: 'callout__body' }, '
|
|
227
|
+
el('div', { class: 'callout__title' }, t('profile.writing.callout.title')),
|
|
228
|
+
el('div', { class: 'callout__body' }, t('profile.writing.callout.body')))) : null,
|
|
224
229
|
hasAny ? kv([
|
|
225
|
-
['
|
|
230
|
+
[t('profile.writing.toneLabel'), p.preferred_tone],
|
|
226
231
|
]) : null,
|
|
227
232
|
emphasis.length ? el('div', { class: p.preferred_tone ? 'mt-3' : '' },
|
|
228
|
-
el('div', { class: 'muted text-sm mb-2' }, '
|
|
233
|
+
el('div', { class: 'muted text-sm mb-2' }, t('profile.writing.emphasisLabel')),
|
|
229
234
|
Chips(emphasis, { accent: true })) : null,
|
|
230
|
-
!hasAny ? el('p', { class: 'muted', style: { margin: '4px 0 0' } }, '
|
|
235
|
+
!hasAny ? el('p', { class: 'muted', style: { margin: '4px 0 0' } }, t('profile.writing.emptyHint')) : null,
|
|
231
236
|
];
|
|
232
237
|
|
|
233
238
|
return Card({
|
|
234
|
-
title: '
|
|
235
|
-
actions: Btn(hasAny ? '
|
|
239
|
+
title: t('profile.writing.title'),
|
|
240
|
+
actions: Btn(hasAny ? t('profile.writing.edit') : t('profile.writing.setup'), { icon: 'edit', sm: true, variant: 'ghost', onClick: () => editWriting(p, reload) }),
|
|
236
241
|
body,
|
|
237
242
|
});
|
|
238
243
|
}
|
|
239
244
|
|
|
240
245
|
function editWriting(p, reload) {
|
|
241
|
-
const tone = Input({ value: p.preferred_tone || '', placeholder: '
|
|
242
|
-
const emphasis = Textarea({ value: (p.emphasis_points || []).join('\n'), attrs: { rows: '5' }, placeholder: '
|
|
246
|
+
const tone = Input({ value: p.preferred_tone || '', placeholder: t('profile.writing.ph.tone') });
|
|
247
|
+
const emphasis = Textarea({ value: (p.emphasis_points || []).join('\n'), attrs: { rows: '5' }, placeholder: t('profile.writing.ph.emphasis') });
|
|
243
248
|
openModal({
|
|
244
|
-
title: '
|
|
249
|
+
title: t('profile.writing.modal.title'),
|
|
245
250
|
body: el('div', {},
|
|
246
|
-
Field('
|
|
247
|
-
Field('
|
|
251
|
+
Field(t('profile.writing.field.tone'), tone, t('profile.writing.field.toneHint')),
|
|
252
|
+
Field(t('profile.writing.field.emphasis'), emphasis, t('profile.writing.field.emphasisHint')),
|
|
248
253
|
),
|
|
249
254
|
footer: (close) => [
|
|
250
|
-
Btn('
|
|
251
|
-
SubmitBtn('
|
|
255
|
+
Btn(t('profile.writing.cancel'), { onClick: close }),
|
|
256
|
+
SubmitBtn(t('profile.writing.save'), async () => {
|
|
252
257
|
await put('/api/profile', {
|
|
253
258
|
preferred_tone: val(tone).trim(),
|
|
254
259
|
emphasis_points: linesToArray(emphasis.value),
|
|
255
260
|
});
|
|
256
|
-
toastOk('
|
|
261
|
+
toastOk(t('profile.writing.saved'));
|
|
257
262
|
close();
|
|
258
263
|
await reload();
|
|
259
264
|
}),
|
|
@@ -264,17 +269,17 @@ function editWriting(p, reload) {
|
|
|
264
269
|
/* ---------------------------------------------------- 4. 경력 (Experiences) */
|
|
265
270
|
|
|
266
271
|
function ExperiencesCard(items, reload) {
|
|
267
|
-
const add = Btn('
|
|
272
|
+
const add = Btn(t('profile.exp.add'), { icon: 'plus', sm: true, variant: 'ghost', onClick: () => editExperience(null, reload) });
|
|
268
273
|
|
|
269
274
|
if (!items.length) {
|
|
270
275
|
return Card({
|
|
271
|
-
title: '
|
|
276
|
+
title: t('profile.exp.title'),
|
|
272
277
|
actions: add,
|
|
273
278
|
body: EmptyState({
|
|
274
279
|
iconName: 'briefcase',
|
|
275
|
-
title: '
|
|
276
|
-
body: '
|
|
277
|
-
action: Btn('
|
|
280
|
+
title: t('profile.exp.empty.title'),
|
|
281
|
+
body: t('profile.exp.empty.body'),
|
|
282
|
+
action: Btn(t('profile.exp.add'), { icon: 'plus', variant: 'primary', onClick: () => editExperience(null, reload) }),
|
|
278
283
|
}),
|
|
279
284
|
});
|
|
280
285
|
}
|
|
@@ -290,8 +295,8 @@ function ExperiencesCard(items, reload) {
|
|
|
290
295
|
x.employment_type ? el('span', { class: 'muted text-sm' }, x.employment_type) : null),
|
|
291
296
|
periodLabel(x) ? el('div', { class: 'muted text-sm' }, periodLabel(x)) : null),
|
|
292
297
|
el('div', { class: 'flex gap-2' },
|
|
293
|
-
IconBtn('edit', { title: '
|
|
294
|
-
IconBtn('trash', { title: '
|
|
298
|
+
IconBtn('edit', { title: t('profile.exp.editTitle'), onClick: () => editExperience(x, reload) }),
|
|
299
|
+
IconBtn('trash', { title: t('profile.exp.deleteTitle'), variant: 'danger', onClick: () => removeExperience(x, reload) }))),
|
|
295
300
|
x.description ? el('div', { class: 'doc-preview mt-2' }, x.description) : null,
|
|
296
301
|
(x.achievements || []).length ? el('ul', { class: 'mt-2', style: { margin: '8px 0 0', paddingLeft: '18px' } },
|
|
297
302
|
...x.achievements.map((a) => el('li', { class: 'text-secondary text-sm', style: { marginBottom: '3px' } }, a))) : null,
|
|
@@ -299,51 +304,51 @@ function ExperiencesCard(items, reload) {
|
|
|
299
304
|
)));
|
|
300
305
|
|
|
301
306
|
return Card({
|
|
302
|
-
title: '
|
|
303
|
-
sub:
|
|
307
|
+
title: t('profile.exp.title'),
|
|
308
|
+
sub: t('profile.exp.count', { count: items.length }),
|
|
304
309
|
actions: add,
|
|
305
310
|
body: el('div', { class: 'timeline' }, ...rows),
|
|
306
311
|
});
|
|
307
312
|
}
|
|
308
313
|
|
|
309
314
|
async function removeExperience(x, reload) {
|
|
310
|
-
const ok = await confirmDialog({ title: '
|
|
315
|
+
const ok = await confirmDialog({ title: t('profile.exp.delete.title'), message: t('profile.exp.delete.message', { company: x.company }), confirmLabel: t('profile.exp.delete.confirm'), danger: true });
|
|
311
316
|
if (!ok) return;
|
|
312
|
-
try { await del(`/api/experiences/${x.id}`); toastOk('
|
|
317
|
+
try { await del(`/api/experiences/${x.id}`); toastOk(t('profile.exp.deleted')); await reload(); }
|
|
313
318
|
catch (e) { toastError(e); }
|
|
314
319
|
}
|
|
315
320
|
|
|
316
321
|
function editExperience(x, reload) {
|
|
317
322
|
const r = x || {};
|
|
318
|
-
const company = Input({ value: r.company || '', placeholder: '
|
|
319
|
-
const role = Input({ value: r.role || '', placeholder: '
|
|
320
|
-
const empType = Input({ value: r.employment_type || '', placeholder: '
|
|
321
|
-
const start = Input({ value: r.start_date || '', placeholder: '
|
|
322
|
-
const end = Input({ value: r.end_date || '', placeholder: '
|
|
323
|
+
const company = Input({ value: r.company || '', placeholder: t('profile.exp.ph.company') });
|
|
324
|
+
const role = Input({ value: r.role || '', placeholder: t('profile.exp.ph.role') });
|
|
325
|
+
const empType = Input({ value: r.employment_type || '', placeholder: t('profile.exp.ph.empType') });
|
|
326
|
+
const start = Input({ value: r.start_date || '', placeholder: t('profile.exp.ph.month') });
|
|
327
|
+
const end = Input({ value: r.end_date || '', placeholder: t('profile.exp.ph.month') });
|
|
323
328
|
const isCurrent = el('input', { type: 'checkbox', checked: !!r.is_current });
|
|
324
329
|
const sync = () => { end.disabled = isCurrent.checked; if (isCurrent.checked) end.value = ''; };
|
|
325
330
|
isCurrent.addEventListener('change', sync);
|
|
326
|
-
const desc = Textarea({ value: r.description || '', attrs: { rows: '3' }, placeholder: '
|
|
327
|
-
const achievements = Textarea({ value: (r.achievements || []).join('\n'), attrs: { rows: '4' }, placeholder: '
|
|
328
|
-
const tech = Input({ value: (r.tech || []).join(', '), placeholder: '
|
|
331
|
+
const desc = Textarea({ value: r.description || '', attrs: { rows: '3' }, placeholder: t('profile.exp.ph.desc') });
|
|
332
|
+
const achievements = Textarea({ value: (r.achievements || []).join('\n'), attrs: { rows: '4' }, placeholder: t('profile.exp.ph.achievements') });
|
|
333
|
+
const tech = Input({ value: (r.tech || []).join(', '), placeholder: t('profile.exp.ph.tech') });
|
|
329
334
|
sync();
|
|
330
335
|
|
|
331
336
|
openModal({
|
|
332
|
-
title: x ? '
|
|
337
|
+
title: x ? t('profile.exp.modal.editTitle') : t('profile.exp.modal.addTitle'),
|
|
333
338
|
size: 'lg',
|
|
334
339
|
body: el('div', {},
|
|
335
|
-
Field('
|
|
336
|
-
el('div', { class: 'grid grid--2' }, Field('
|
|
337
|
-
el('div', { class: 'grid grid--2' }, Field('
|
|
338
|
-
Field(null, el('label', { class: 'flex gap-2', style: { alignItems: 'center', fontWeight: '500' } }, isCurrent, el('span', {}, '
|
|
339
|
-
Field('
|
|
340
|
-
Field('
|
|
341
|
-
Field('
|
|
340
|
+
Field(t('profile.exp.field.company'), company),
|
|
341
|
+
el('div', { class: 'grid grid--2' }, Field(t('profile.exp.field.role'), role), Field(t('profile.exp.field.empType'), empType)),
|
|
342
|
+
el('div', { class: 'grid grid--2' }, Field(t('profile.exp.field.start'), start), Field(t('profile.exp.field.end'), end)),
|
|
343
|
+
Field(null, el('label', { class: 'flex gap-2', style: { alignItems: 'center', fontWeight: '500' } }, isCurrent, el('span', {}, t('profile.exp.field.isCurrent')))),
|
|
344
|
+
Field(t('profile.exp.field.desc'), desc),
|
|
345
|
+
Field(t('profile.exp.field.achievements'), achievements, t('profile.exp.field.achievementsHint')),
|
|
346
|
+
Field(t('profile.exp.field.tech'), tech, t('profile.exp.field.techHint')),
|
|
342
347
|
),
|
|
343
348
|
footer: (close) => [
|
|
344
|
-
Btn('
|
|
345
|
-
SubmitBtn(x ? '
|
|
346
|
-
if (!val(company).trim()) throw new Error('
|
|
349
|
+
Btn(t('profile.exp.cancel'), { onClick: close }),
|
|
350
|
+
SubmitBtn(x ? t('profile.exp.saveEdit') : t('profile.exp.saveNew'), async () => {
|
|
351
|
+
if (!val(company).trim()) throw new Error(t('profile.exp.validation.company'));
|
|
347
352
|
const payload = {
|
|
348
353
|
company: val(company).trim(),
|
|
349
354
|
role: val(role).trim(),
|
|
@@ -357,7 +362,7 @@ function editExperience(x, reload) {
|
|
|
357
362
|
};
|
|
358
363
|
if (x) await put(`/api/experiences/${x.id}`, payload);
|
|
359
364
|
else await post('/api/experiences', payload);
|
|
360
|
-
toastOk(x ? '
|
|
365
|
+
toastOk(x ? t('profile.exp.updated') : t('profile.exp.added'));
|
|
361
366
|
close();
|
|
362
367
|
await reload();
|
|
363
368
|
}),
|
|
@@ -368,17 +373,17 @@ function editExperience(x, reload) {
|
|
|
368
373
|
/* ----------------------------------------------------- 5. 프로젝트 (Projects) */
|
|
369
374
|
|
|
370
375
|
function ProjectsCard(items, reload) {
|
|
371
|
-
const add = Btn('
|
|
376
|
+
const add = Btn(t('profile.proj.add'), { icon: 'plus', sm: true, variant: 'ghost', onClick: () => editProject(null, reload) });
|
|
372
377
|
|
|
373
378
|
if (!items.length) {
|
|
374
379
|
return Card({
|
|
375
|
-
title: '
|
|
380
|
+
title: t('profile.proj.title'),
|
|
376
381
|
actions: add,
|
|
377
382
|
body: EmptyState({
|
|
378
383
|
iconName: 'layers',
|
|
379
|
-
title: '
|
|
380
|
-
body: '
|
|
381
|
-
action: Btn('
|
|
384
|
+
title: t('profile.proj.empty.title'),
|
|
385
|
+
body: t('profile.proj.empty.body'),
|
|
386
|
+
action: Btn(t('profile.proj.add'), { icon: 'plus', variant: 'primary', onClick: () => editProject(null, reload) }),
|
|
382
387
|
}),
|
|
383
388
|
});
|
|
384
389
|
}
|
|
@@ -391,9 +396,9 @@ function ProjectsCard(items, reload) {
|
|
|
391
396
|
x.role ? el('span', { class: 'text-secondary text-sm' }, x.role) : null),
|
|
392
397
|
periodProject(x) ? el('div', { class: 'muted text-sm' }, periodProject(x)) : null),
|
|
393
398
|
el('div', { class: 'flex gap-2' },
|
|
394
|
-
x.url ? el('a', { class: 'btn btn--ghost icon-btn', href: x.url, title: '
|
|
395
|
-
IconBtn('edit', { title: '
|
|
396
|
-
IconBtn('trash', { title: '
|
|
399
|
+
x.url ? el('a', { class: 'btn btn--ghost icon-btn', href: x.url, title: t('profile.proj.openLink'), attrs: { target: '_blank', rel: 'noopener', 'aria-label': t('profile.proj.openLink') } }, icon('external')) : null,
|
|
400
|
+
IconBtn('edit', { title: t('profile.proj.editTitle'), onClick: () => editProject(x, reload) }),
|
|
401
|
+
IconBtn('trash', { title: t('profile.proj.deleteTitle'), variant: 'danger', onClick: () => removeProject(x, reload) }))),
|
|
397
402
|
x.description ? el('div', { class: 'doc-preview mt-2' }, x.description) : null,
|
|
398
403
|
(x.highlights || []).length ? el('ul', { style: { margin: '8px 0 0', paddingLeft: '18px' } },
|
|
399
404
|
...x.highlights.map((h) => el('li', { class: 'text-secondary text-sm', style: { marginBottom: '3px' } }, h))) : null,
|
|
@@ -401,8 +406,8 @@ function ProjectsCard(items, reload) {
|
|
|
401
406
|
));
|
|
402
407
|
|
|
403
408
|
return Card({
|
|
404
|
-
title: '
|
|
405
|
-
sub:
|
|
409
|
+
title: t('profile.proj.title'),
|
|
410
|
+
sub: t('profile.proj.count', { count: items.length }),
|
|
406
411
|
actions: add,
|
|
407
412
|
body: el('div', { class: 'stack-3' }, ...cards),
|
|
408
413
|
});
|
|
@@ -414,38 +419,38 @@ function periodProject(x) {
|
|
|
414
419
|
}
|
|
415
420
|
|
|
416
421
|
async function removeProject(x, reload) {
|
|
417
|
-
const ok = await confirmDialog({ title: '
|
|
422
|
+
const ok = await confirmDialog({ title: t('profile.proj.delete.title'), message: t('profile.proj.delete.message', { name: x.name }), confirmLabel: t('profile.proj.delete.confirm'), danger: true });
|
|
418
423
|
if (!ok) return;
|
|
419
|
-
try { await del(`/api/projects/${x.id}`); toastOk('
|
|
424
|
+
try { await del(`/api/projects/${x.id}`); toastOk(t('profile.proj.deleted')); await reload(); }
|
|
420
425
|
catch (e) { toastError(e); }
|
|
421
426
|
}
|
|
422
427
|
|
|
423
428
|
function editProject(x, reload) {
|
|
424
429
|
const r = x || {};
|
|
425
|
-
const name = Input({ value: r.name || '', placeholder: '
|
|
426
|
-
const role = Input({ value: r.role || '', placeholder: '
|
|
427
|
-
const start = Input({ value: r.start_date || '', placeholder: '
|
|
428
|
-
const end = Input({ value: r.end_date || '', placeholder: '
|
|
429
|
-
const url = Input({ value: r.url || '', placeholder: '
|
|
430
|
-
const desc = Textarea({ value: r.description || '', attrs: { rows: '3' }, placeholder: '
|
|
431
|
-
const highlights = Textarea({ value: (r.highlights || []).join('\n'), attrs: { rows: '4' }, placeholder: '
|
|
432
|
-
const tech = Input({ value: (r.tech || []).join(', '), placeholder: '
|
|
430
|
+
const name = Input({ value: r.name || '', placeholder: t('profile.proj.ph.name') });
|
|
431
|
+
const role = Input({ value: r.role || '', placeholder: t('profile.proj.ph.role') });
|
|
432
|
+
const start = Input({ value: r.start_date || '', placeholder: t('profile.proj.ph.month') });
|
|
433
|
+
const end = Input({ value: r.end_date || '', placeholder: t('profile.proj.ph.month') });
|
|
434
|
+
const url = Input({ value: r.url || '', placeholder: t('profile.proj.ph.url') });
|
|
435
|
+
const desc = Textarea({ value: r.description || '', attrs: { rows: '3' }, placeholder: t('profile.proj.ph.desc') });
|
|
436
|
+
const highlights = Textarea({ value: (r.highlights || []).join('\n'), attrs: { rows: '4' }, placeholder: t('profile.proj.ph.highlights') });
|
|
437
|
+
const tech = Input({ value: (r.tech || []).join(', '), placeholder: t('profile.proj.ph.tech') });
|
|
433
438
|
|
|
434
439
|
openModal({
|
|
435
|
-
title: x ? '
|
|
440
|
+
title: x ? t('profile.proj.modal.editTitle') : t('profile.proj.modal.addTitle'),
|
|
436
441
|
size: 'lg',
|
|
437
442
|
body: el('div', {},
|
|
438
|
-
Field('
|
|
439
|
-
el('div', { class: 'grid grid--2' }, Field('
|
|
440
|
-
el('div', { class: 'grid grid--2' }, Field('
|
|
441
|
-
Field('
|
|
442
|
-
Field('
|
|
443
|
-
Field('
|
|
443
|
+
Field(t('profile.proj.field.name'), name),
|
|
444
|
+
el('div', { class: 'grid grid--2' }, Field(t('profile.proj.field.role'), role), Field(t('profile.proj.field.url'), url)),
|
|
445
|
+
el('div', { class: 'grid grid--2' }, Field(t('profile.proj.field.start'), start), Field(t('profile.proj.field.end'), end)),
|
|
446
|
+
Field(t('profile.proj.field.desc'), desc),
|
|
447
|
+
Field(t('profile.proj.field.highlights'), highlights, t('profile.proj.field.highlightsHint')),
|
|
448
|
+
Field(t('profile.proj.field.tech'), tech, t('profile.proj.field.techHint')),
|
|
444
449
|
),
|
|
445
450
|
footer: (close) => [
|
|
446
|
-
Btn('
|
|
447
|
-
SubmitBtn(x ? '
|
|
448
|
-
if (!val(name).trim()) throw new Error('
|
|
451
|
+
Btn(t('profile.proj.cancel'), { onClick: close }),
|
|
452
|
+
SubmitBtn(x ? t('profile.proj.saveEdit') : t('profile.proj.saveNew'), async () => {
|
|
453
|
+
if (!val(name).trim()) throw new Error(t('profile.proj.validation.name'));
|
|
449
454
|
const payload = {
|
|
450
455
|
name: val(name).trim(),
|
|
451
456
|
role: val(role).trim(),
|
|
@@ -458,7 +463,7 @@ function editProject(x, reload) {
|
|
|
458
463
|
};
|
|
459
464
|
if (x) await put(`/api/projects/${x.id}`, payload);
|
|
460
465
|
else await post('/api/projects', payload);
|
|
461
|
-
toastOk(x ? '
|
|
466
|
+
toastOk(x ? t('profile.proj.updated') : t('profile.proj.added'));
|
|
462
467
|
close();
|
|
463
468
|
await reload();
|
|
464
469
|
}),
|
|
@@ -469,17 +474,17 @@ function editProject(x, reload) {
|
|
|
469
474
|
/* -------------------------------------------------------- 6. 기술 (Skills) */
|
|
470
475
|
|
|
471
476
|
function SkillsCard(items, reload) {
|
|
472
|
-
const add = Btn('
|
|
477
|
+
const add = Btn(t('profile.skill.add'), { icon: 'plus', sm: true, variant: 'ghost', onClick: () => editSkill(null, reload) });
|
|
473
478
|
|
|
474
479
|
if (!items.length) {
|
|
475
480
|
return Card({
|
|
476
|
-
title: '
|
|
481
|
+
title: t('profile.skill.title'),
|
|
477
482
|
actions: add,
|
|
478
483
|
body: EmptyState({
|
|
479
484
|
iconName: 'sparkle',
|
|
480
|
-
title: '
|
|
481
|
-
body: '
|
|
482
|
-
action: Btn('
|
|
485
|
+
title: t('profile.skill.empty.title'),
|
|
486
|
+
body: t('profile.skill.empty.body'),
|
|
487
|
+
action: Btn(t('profile.skill.add'), { icon: 'plus', variant: 'primary', onClick: () => editSkill(null, reload) }),
|
|
483
488
|
}),
|
|
484
489
|
});
|
|
485
490
|
}
|
|
@@ -487,7 +492,7 @@ function SkillsCard(items, reload) {
|
|
|
487
492
|
// Group by category (uncategorized last).
|
|
488
493
|
const groups = new Map();
|
|
489
494
|
for (const s of items) {
|
|
490
|
-
const key = s.category || '
|
|
495
|
+
const key = s.category || t('profile.skill.uncategorized');
|
|
491
496
|
if (!groups.has(key)) groups.set(key, []);
|
|
492
497
|
groups.get(key).push(s);
|
|
493
498
|
}
|
|
@@ -500,44 +505,44 @@ function SkillsCard(items, reload) {
|
|
|
500
505
|
el('div', { class: 'flex gap-2 wrap', style: { alignItems: 'baseline' } },
|
|
501
506
|
el('span', { class: 'strong' }, s.name),
|
|
502
507
|
s.level ? el('span', { class: 'badge badge--applied' }, el('span', { class: 'dot' }), s.level) : null,
|
|
503
|
-
s.years != null ? el('span', { class: 'muted text-sm' },
|
|
508
|
+
s.years != null ? el('span', { class: 'muted text-sm' }, t('profile.skill.years', { count: s.years })) : null),
|
|
504
509
|
el('div', { class: 'flex gap-2' },
|
|
505
|
-
IconBtn('edit', { title: '
|
|
506
|
-
IconBtn('trash', { title: '
|
|
510
|
+
IconBtn('edit', { title: t('profile.skill.editTitle'), onClick: () => editSkill(s, reload) }),
|
|
511
|
+
IconBtn('trash', { title: t('profile.skill.deleteTitle'), variant: 'danger', onClick: () => removeSkill(s, reload) })))))));
|
|
507
512
|
|
|
508
513
|
return Card({
|
|
509
|
-
title: '
|
|
510
|
-
sub:
|
|
514
|
+
title: t('profile.skill.title'),
|
|
515
|
+
sub: t('profile.skill.count', { count: items.length }),
|
|
511
516
|
actions: add,
|
|
512
517
|
body: el('div', { class: 'stack-3' }, ...blocks),
|
|
513
518
|
});
|
|
514
519
|
}
|
|
515
520
|
|
|
516
521
|
async function removeSkill(s, reload) {
|
|
517
|
-
const ok = await confirmDialog({ title: '
|
|
522
|
+
const ok = await confirmDialog({ title: t('profile.skill.delete.title'), message: t('profile.skill.delete.message', { name: s.name }), confirmLabel: t('profile.skill.delete.confirm'), danger: true });
|
|
518
523
|
if (!ok) return;
|
|
519
|
-
try { await del(`/api/skills/${s.id}`); toastOk('
|
|
524
|
+
try { await del(`/api/skills/${s.id}`); toastOk(t('profile.skill.deleted')); await reload(); }
|
|
520
525
|
catch (e) { toastError(e); }
|
|
521
526
|
}
|
|
522
527
|
|
|
523
528
|
function editSkill(s, reload) {
|
|
524
529
|
const r = s || {};
|
|
525
|
-
const name = Input({ value: r.name || '', placeholder: '
|
|
526
|
-
const category = Input({ value: r.category || '', placeholder: '
|
|
527
|
-
const level = Input({ value: r.level || '', placeholder: '
|
|
528
|
-
const years = Input({ value: r.years != null ? String(r.years) : '', type: 'number', attrs: { min: '0', step: '0.5' }, placeholder: '
|
|
530
|
+
const name = Input({ value: r.name || '', placeholder: t('profile.skill.ph.name') });
|
|
531
|
+
const category = Input({ value: r.category || '', placeholder: t('profile.skill.ph.category') });
|
|
532
|
+
const level = Input({ value: r.level || '', placeholder: t('profile.skill.ph.level') });
|
|
533
|
+
const years = Input({ value: r.years != null ? String(r.years) : '', type: 'number', attrs: { min: '0', step: '0.5' }, placeholder: t('profile.skill.ph.years') });
|
|
529
534
|
|
|
530
535
|
openModal({
|
|
531
|
-
title: s ? '
|
|
536
|
+
title: s ? t('profile.skill.modal.editTitle') : t('profile.skill.modal.addTitle'),
|
|
532
537
|
body: el('div', {},
|
|
533
|
-
Field('
|
|
534
|
-
Field('
|
|
535
|
-
el('div', { class: 'grid grid--2' }, Field('
|
|
538
|
+
Field(t('profile.skill.field.name'), name),
|
|
539
|
+
Field(t('profile.skill.field.category'), category),
|
|
540
|
+
el('div', { class: 'grid grid--2' }, Field(t('profile.skill.field.level'), level), Field(t('profile.skill.field.years'), years)),
|
|
536
541
|
),
|
|
537
542
|
footer: (close) => [
|
|
538
|
-
Btn('
|
|
539
|
-
SubmitBtn(s ? '
|
|
540
|
-
if (!val(name).trim()) throw new Error('
|
|
543
|
+
Btn(t('profile.skill.cancel'), { onClick: close }),
|
|
544
|
+
SubmitBtn(s ? t('profile.skill.saveEdit') : t('profile.skill.saveNew'), async () => {
|
|
545
|
+
if (!val(name).trim()) throw new Error(t('profile.skill.validation.name'));
|
|
541
546
|
const yrs = val(years).trim();
|
|
542
547
|
const payload = {
|
|
543
548
|
name: val(name).trim(),
|
|
@@ -547,7 +552,7 @@ function editSkill(s, reload) {
|
|
|
547
552
|
if (yrs !== '' && !Number.isNaN(Number(yrs))) payload.years = Number(yrs);
|
|
548
553
|
if (s) await put(`/api/skills/${s.id}`, payload);
|
|
549
554
|
else await post('/api/skills', payload);
|
|
550
|
-
toastOk(s ? '
|
|
555
|
+
toastOk(s ? t('profile.skill.updated') : t('profile.skill.added'));
|
|
551
556
|
close();
|
|
552
557
|
await reload();
|
|
553
558
|
}),
|