careermate 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/AGENTS.md +11 -7
  2. package/INSTALL.md +18 -16
  3. package/README.md +28 -26
  4. package/THIRD_PARTY_NOTICES.md +2 -3
  5. package/bin/careermate.mjs +8 -1
  6. package/dist/career-os/README.md +8 -3
  7. package/dist/career-os/eop/career-description.md +136 -0
  8. package/dist/career-os/eop/cover-letter.md +7 -5
  9. package/dist/career-os/eop/fit-analysis.md +15 -5
  10. package/dist/career-os/eop/interview-prep.md +209 -21
  11. package/dist/career-os/eop/job-analysis.md +5 -3
  12. package/dist/career-os/eop/profile-extraction.md +5 -4
  13. package/dist/career-os/knowledge/cover-letter.md +4 -4
  14. package/dist/career-os/knowledge/fit-matching.md +8 -8
  15. package/dist/career-os/knowledge/interview-behavioral.md +76 -8
  16. package/dist/career-os/knowledge/interview-technical.md +106 -4
  17. package/dist/career-os/knowledge/linkedin-profile.md +1 -1
  18. package/dist/career-os/knowledge/networking-referrals.md +1 -1
  19. package/dist/career-os/knowledge/offer-evaluation-decision.md +10 -10
  20. package/dist/career-os/knowledge/portfolio.md +3 -3
  21. package/dist/career-os/knowledge/verifiers/ats-compat.md +8 -4
  22. package/dist/career-os/knowledge/verifiers/human-voice.md +2 -2
  23. package/dist/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  24. package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  25. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -1
  26. package/dist/doctor.mjs +1019 -127
  27. package/dist/mcp.mjs +1036 -126
  28. package/dist/migrate.mjs +52 -1
  29. package/dist/public/app.js +42 -16
  30. package/dist/public/i18n/en/applications.js +23 -0
  31. package/dist/public/i18n/en/common.js +22 -0
  32. package/dist/public/i18n/en/documents.js +133 -0
  33. package/dist/public/i18n/en/home.js +75 -0
  34. package/dist/public/i18n/en/interview.js +69 -0
  35. package/dist/public/i18n/en/jobs.js +152 -0
  36. package/dist/public/i18n/en/nav.js +18 -0
  37. package/dist/public/i18n/en/profile.js +201 -0
  38. package/dist/public/i18n/en/settings.js +127 -0
  39. package/dist/public/i18n/en/status.js +22 -0
  40. package/dist/public/i18n/en.js +17 -0
  41. package/dist/public/i18n/ko/applications.js +23 -0
  42. package/dist/public/i18n/ko/common.js +22 -0
  43. package/dist/public/i18n/ko/documents.js +133 -0
  44. package/dist/public/i18n/ko/home.js +63 -0
  45. package/dist/public/i18n/ko/interview.js +69 -0
  46. package/dist/public/i18n/ko/jobs.js +152 -0
  47. package/dist/public/i18n/ko/nav.js +18 -0
  48. package/dist/public/i18n/ko/profile.js +201 -0
  49. package/dist/public/i18n/ko/settings.js +127 -0
  50. package/dist/public/i18n/ko/status.js +23 -0
  51. package/dist/public/i18n/ko.js +18 -0
  52. package/dist/public/i18n.js +55 -0
  53. package/dist/public/index.html +1 -0
  54. package/dist/public/lib.js +25 -17
  55. package/dist/public/pages/applications.js +17 -21
  56. package/dist/public/pages/documents.js +153 -107
  57. package/dist/public/pages/home.js +88 -53
  58. package/dist/public/pages/interview.js +105 -51
  59. package/dist/public/pages/jobs.js +330 -106
  60. package/dist/public/pages/profile.js +157 -152
  61. package/dist/public/pages/settings.js +270 -72
  62. package/dist/public/styles.css +38 -3
  63. package/dist/web.mjs +859 -103
  64. package/docs/FAQ.md +26 -5
  65. package/docs/MCP_TOOLS.md +26 -12
  66. package/docs/START_WORKFLOW.md +2 -2
  67. package/docs/SUPPORTED_AI_APPS.md +13 -0
  68. package/docs/WORKFLOWS.md +80 -23
  69. package/docs/career-os/README.md +8 -3
  70. package/docs/career-os/eop/career-description.md +136 -0
  71. package/docs/career-os/eop/cover-letter.md +7 -5
  72. package/docs/career-os/eop/fit-analysis.md +15 -5
  73. package/docs/career-os/eop/interview-prep.md +209 -21
  74. package/docs/career-os/eop/job-analysis.md +5 -3
  75. package/docs/career-os/eop/profile-extraction.md +5 -4
  76. package/docs/career-os/knowledge/cover-letter.md +4 -4
  77. package/docs/career-os/knowledge/fit-matching.md +8 -8
  78. package/docs/career-os/knowledge/interview-behavioral.md +76 -8
  79. package/docs/career-os/knowledge/interview-technical.md +106 -4
  80. package/docs/career-os/knowledge/linkedin-profile.md +1 -1
  81. package/docs/career-os/knowledge/networking-referrals.md +1 -1
  82. package/docs/career-os/knowledge/offer-evaluation-decision.md +10 -10
  83. package/docs/career-os/knowledge/portfolio.md +3 -3
  84. package/docs/career-os/knowledge/verifiers/ats-compat.md +8 -4
  85. package/docs/career-os/knowledge/verifiers/human-voice.md +2 -2
  86. package/docs/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  87. package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  88. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -1
  89. package/package.json +2 -2
  90. package/packages/core/src/onboarding.ts +2 -1
  91. package/packages/core/src/services.ts +138 -21
  92. package/packages/core/src/summary.ts +59 -1
  93. package/packages/core/src/verify/lint.ts +4 -0
  94. package/packages/db/src/index.ts +1 -0
  95. package/packages/db/src/repositories.ts +0 -0
  96. package/packages/db/src/schema.ts +17 -0
  97. package/packages/exporters/src/index.ts +28 -17
  98. package/packages/knowledge/src/index.ts +46 -7
  99. package/packages/mcp-tools/src/bridge.ts +194 -13
  100. package/packages/mcp-tools/src/tools.ts +390 -38
  101. package/packages/mcp-tools/src/update.ts +31 -16
  102. package/packages/parsers/src/documents.ts +12 -1
  103. package/packages/prompts/src/humanize.ts +46 -0
  104. package/packages/prompts/src/index.ts +5 -1
  105. package/packages/prompts/src/install.ts +4 -4
  106. package/packages/prompts/src/onboarding.ts +4 -4
  107. package/packages/prompts/src/system.ts +17 -8
  108. package/packages/shared/src/enums.ts +14 -4
  109. package/packages/shared/src/schemas.ts +41 -0
  110. package/packages/workflows/src/definitions.ts +47 -10
  111. package/scripts/init.ts +284 -13
  112. package/dist/site/analytics.js +0 -97
  113. package/dist/site/favicon.svg +0 -1
  114. package/dist/site/fonts/PretendardVariable.woff2 +0 -0
  115. package/dist/site/icon-180.png +0 -0
  116. package/dist/site/icon-512.png +0 -0
  117. package/dist/site/index.html +0 -565
  118. package/dist/site/landing.js +0 -54
  119. package/dist/site/llms-install.txt +0 -218
  120. package/dist/site/robots.txt +0 -4
  121. package/dist/site/sitemap.xml +0 -21
  122. package/dist/site/start.html +0 -353
  123. package/dist/site/style.css +0 -559
  124. package/dist/site/vercel.json +0 -20
@@ -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 ? `${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 `${start} ~ ${end}`;
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: `${pct}%`,
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
- ['이름', p.name],
89
- ['이메일', p.email],
90
- ['연락처', p.phone],
91
- ['지역', p.location],
92
- ['한 줄 소개', p.headline],
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('정보 입력', { icon: 'edit', variant: 'primary', onClick: () => editBasic(p, reload) }),
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('수정', { icon: 'edit', sm: true, variant: 'ghost', onClick: () => editBasic(p, reload) }) : null,
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: 'you@example.com' });
118
- const phone = Input({ value: p.phone || '', placeholder: '010-0000-0000' });
119
- const location = Input({ value: p.location || '', placeholder: '서울' });
120
- const headline = Input({ value: p.headline || '', placeholder: '예: 5년차 백엔드 엔지니어' });
121
- const summary = Textarea({ value: p.summary || '', attrs: { rows: '5' }, placeholder: '자기소개 요약' });
122
- // links as one "라벨 | URL" per line
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: 'GitHub | https://github.com/...\n포트폴리오 | https://...',
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('이름', name),
133
- el('div', { class: 'grid grid--2' }, Field('이메일', email), Field('연락처', phone)),
134
- Field('지역', location),
135
- Field('한 줄 소개', headline, '직무 타이틀이나 강점을 한 문장으로'),
136
- Field('자기소개 요약', summary),
137
- Field('링크', linksTa, '한 줄에 하나씩 "라벨 | URL" 형식으로 입력하세요.'),
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('취소', { onClick: close }),
141
- SubmitBtn('기본 정보 저장', async () => {
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('희망 조건 입력', { icon: 'edit', variant: 'primary', onClick: () => editDesired(p, reload) }),
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('수정', { icon: 'edit', sm: true, variant: 'ghost', onClick: () => editDesired(p, reload) }) : null,
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: '예: 연봉 6,000 이상 / 서울·재택 / 정규직' });
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('희망 직무', roles, '쉼표(,)로 구분해 여러 개 입력할 수 있어요.'),
195
- Field('희망 조건', conditions, '연봉·지역·근무형태 등 자유롭게'),
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('취소', { onClick: close }),
199
- SubmitBtn('희망 조건 저장', async () => {
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' }, 'AI 글쓰기 품질에 직접 반영돼요'),
223
- el('div', { class: 'callout__body' }, '선호 문체와 강조 포인트를 적어 두면 자기소개서·면접 답변의 톤이 일관되게 맞춰집니다.'))) : null,
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
- ['선호 문체', p.preferred_tone],
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' } }, '아직 선호 문체·강조 포인트가 없어요.') : null,
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 ? '수정' : '설정', { icon: 'edit', sm: true, variant: 'ghost', onClick: () => editWriting(p, reload) }),
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: '문제 해결 능력\n주도적인 협업\n정량 성과' });
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('선호 문체', tone, '예: 담백하고 구체적 / 자신감 있게 / 진솔하게'),
247
- Field('강조 포인트', emphasis, '한 줄에 하나씩. AI가 글을 쓸 때 이 포인트를 우선 반영해요.'),
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('취소', { onClick: close }),
251
- SubmitBtn('설정 저장', async () => {
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('경력 추가', { icon: 'plus', sm: true, variant: 'ghost', onClick: () => editExperience(null, reload) });
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('경력 추가', { icon: 'plus', variant: 'primary', onClick: () => editExperience(null, reload) }),
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: '수정', onClick: () => editExperience(x, reload) }),
294
- IconBtn('trash', { title: '삭제', variant: 'danger', onClick: () => removeExperience(x, reload) }))),
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: `${items.length}건`,
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: '경력 삭제', message: `"${x.company}" 경력을 삭제할까요?`, confirmLabel: '삭제', danger: true });
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('경력을 삭제했어요.'); await reload(); }
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: 'YYYY-MM' });
322
- const end = Input({ value: r.end_date || '', placeholder: 'YYYY-MM' });
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: '결제 전환율 12% 개선\n월 배포 횟수 3배 증가' });
328
- const tech = Input({ value: (r.tech || []).join(', '), placeholder: 'TypeScript, Node.js, PostgreSQL' });
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('회사', company),
336
- el('div', { class: 'grid grid--2' }, Field('직무', role), Field('고용형태', empType)),
337
- el('div', { class: 'grid grid--2' }, Field('시작 (YYYY-MM)', start), Field('종료 (YYYY-MM)', end)),
338
- Field(null, el('label', { class: 'flex gap-2', style: { alignItems: 'center', fontWeight: '500' } }, isCurrent, el('span', {}, '현재 재직 중'))),
339
- Field('업무 설명', desc),
340
- Field('주요 성과', achievements, '한 줄에 하나씩. 정량 지표를 함께 적으면 좋아요.'),
341
- Field('사용 기술', tech, '쉼표(,)로 구분'),
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('취소', { onClick: close }),
345
- SubmitBtn(x ? '경력 저장' : '경력 추가', async () => {
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('프로젝트 추가', { icon: 'plus', sm: true, variant: 'ghost', onClick: () => editProject(null, reload) });
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('프로젝트 추가', { icon: 'plus', variant: 'primary', onClick: () => editProject(null, reload) }),
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: '링크 열기', attrs: { target: '_blank', rel: 'noopener', 'aria-label': '링크 열기' } }, icon('external')) : null,
395
- IconBtn('edit', { title: '수정', onClick: () => editProject(x, reload) }),
396
- IconBtn('trash', { title: '삭제', variant: 'danger', onClick: () => removeProject(x, reload) }))),
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: `${items.length}건`,
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: '프로젝트 삭제', message: `"${x.name}" 프로젝트를 삭제할까요?`, confirmLabel: '삭제', danger: true });
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('프로젝트를 삭제했어요.'); await reload(); }
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: 'YYYY-MM' });
428
- const end = Input({ value: r.end_date || '', placeholder: 'YYYY-MM' });
429
- const url = Input({ value: r.url || '', placeholder: 'https://...' });
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: 'React, AWS, GraphQL' });
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('프로젝트명', name),
439
- el('div', { class: 'grid grid--2' }, Field('역할', role), Field('링크', url)),
440
- el('div', { class: 'grid grid--2' }, Field('시작 (YYYY-MM)', start), Field('종료 (YYYY-MM)', end)),
441
- Field('설명', desc),
442
- Field('주요 내용', highlights, '한 줄에 하나씩'),
443
- Field('사용 기술', tech, '쉼표(,)로 구분'),
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('취소', { onClick: close }),
447
- SubmitBtn(x ? '프로젝트 저장' : '프로젝트 추가', async () => {
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('기술 추가', { icon: 'plus', sm: true, variant: 'ghost', onClick: () => editSkill(null, reload) });
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('기술 추가', { icon: 'plus', variant: 'primary', onClick: () => editSkill(null, reload) }),
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' }, `${s.years}년`) : null),
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: '수정', onClick: () => editSkill(s, reload) }),
506
- IconBtn('trash', { title: '삭제', variant: 'danger', onClick: () => removeSkill(s, reload) })))))));
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: `${items.length}개`,
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: '기술 삭제', message: `"${s.name}"을(를) 삭제할까요?`, confirmLabel: '삭제', danger: true });
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('기술을 삭제했어요.'); await reload(); }
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('기술명', name),
534
- Field('분류', category),
535
- el('div', { class: 'grid grid--2' }, Field('숙련도', level), Field('연차', years)),
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('취소', { onClick: close }),
539
- SubmitBtn(s ? '기술 저장' : '기술 추가', async () => {
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
  }),