careermate 0.0.9 → 0.0.10
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/dist/doctor.mjs +17283 -6559
- package/dist/mcp.mjs +13982 -3840
- package/dist/migrate.mjs +14241 -3709
- package/dist/pdf.worker.min.mjs +4 -4
- package/dist/public/app.js +10 -19
- package/dist/public/i18n/en/documents.js +1 -0
- package/dist/public/i18n/en/home.js +8 -1
- package/dist/public/i18n/en/jobs.js +2 -0
- package/dist/public/i18n/en/nav.js +1 -0
- package/dist/public/i18n/ko/documents.js +1 -0
- package/dist/public/i18n/ko/home.js +8 -1
- package/dist/public/i18n/ko/jobs.js +2 -0
- package/dist/public/i18n/ko/nav.js +1 -0
- package/dist/public/pages/documents.js +9 -5
- package/dist/public/pages/home.js +12 -4
- package/dist/public/pages/insights.js +156 -0
- package/dist/public/pages/jobs.js +21 -3
- package/dist/public/styles.css +3 -0
- package/dist/web.mjs +16581 -5498
- package/docs/LOCAL_FIRST.md +32 -0
- package/docs/REVIEW_IMPLEMENTATION_PLAN.md +22 -0
- package/package.json +7 -5
- package/packages/core/src/data-health.ts +161 -0
- package/packages/core/src/fit-explain.ts +63 -0
- package/packages/core/src/index.ts +3 -0
- package/packages/core/src/jobsearch.ts +96 -55
- package/packages/core/src/reuse-insights.ts +126 -0
- package/packages/db/src/connection.ts +46 -26
- package/packages/db/src/index.ts +2 -0
- package/packages/db/src/interview-retrospectives.ts +149 -0
- package/packages/db/src/provenance.ts +125 -0
- package/packages/prompts/src/system.ts +21 -64
- package/tsconfig.json +9 -10
package/dist/public/app.js
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
import { el, icon, get, navigate, toastError, Spinner, clear } from '/lib.js';
|
|
7
7
|
import { t, onLangChange } from '/i18n.js';
|
|
8
8
|
|
|
9
|
-
// 라벨은 t('nav.<id>')로 해석 — 로케일 전환 시 재렌더로 갱신된다.
|
|
10
9
|
const ROUTES = [
|
|
11
10
|
{ id: 'home', icon: 'home', module: '/pages/home.js' },
|
|
12
11
|
{ id: 'profile', icon: 'user', module: '/pages/profile.js' },
|
|
@@ -14,6 +13,7 @@ const ROUTES = [
|
|
|
14
13
|
{ id: 'applications', icon: 'layers', module: '/pages/applications.js', countKey: 'active_applications' },
|
|
15
14
|
{ id: 'documents', icon: 'file', module: '/pages/documents.js', countKey: 'cover_letters' },
|
|
16
15
|
{ id: 'interview', icon: 'mic', module: '/pages/interview.js', countKey: 'interview_pending' },
|
|
16
|
+
{ id: 'insights', icon: 'target', module: '/pages/insights.js' },
|
|
17
17
|
{ id: 'settings', icon: 'settings', module: '/pages/settings.js' },
|
|
18
18
|
];
|
|
19
19
|
|
|
@@ -24,6 +24,10 @@ const actionsEl = document.getElementById('topbar-actions');
|
|
|
24
24
|
const routeStatusEl = document.getElementById('route-status');
|
|
25
25
|
const moduleCache = new Map();
|
|
26
26
|
|
|
27
|
+
function removePrivateRepositoryLinks(root = document) {
|
|
28
|
+
root.querySelectorAll?.('a[href*="github.com/osntak/careermate"]').forEach((link) => link.remove());
|
|
29
|
+
}
|
|
30
|
+
|
|
27
31
|
function parseHash() {
|
|
28
32
|
const raw = location.hash.replace(/^#\/?/, '');
|
|
29
33
|
const segments = raw.split('/').filter(Boolean);
|
|
@@ -35,7 +39,7 @@ function renderNav(counts = {}) {
|
|
|
35
39
|
navEl.append(el('div', { class: 'nav__label' }, t('nav.section.workspace')));
|
|
36
40
|
const { page } = parseHash();
|
|
37
41
|
for (const r of ROUTES) {
|
|
38
|
-
if (r.id === '
|
|
42
|
+
if (r.id === 'insights') navEl.append(el('div', { class: 'nav__label' }, t('nav.section.system')));
|
|
39
43
|
const count = r.countKey ? counts[r.countKey] : null;
|
|
40
44
|
navEl.append(el('a', {
|
|
41
45
|
class: `nav__item${page === r.id ? ' is-active' : ''}`,
|
|
@@ -67,7 +71,6 @@ async function route() {
|
|
|
67
71
|
const { page, params } = parseHash();
|
|
68
72
|
const def = ROUTES.find((r) => r.id === page) || ROUTES[0];
|
|
69
73
|
|
|
70
|
-
// active nav state
|
|
71
74
|
navEl.querySelectorAll('.nav__item').forEach((n) => n.classList.toggle('is-active', n.getAttribute('href') === `#/${def.id}`));
|
|
72
75
|
const pageTitle = t('nav.' + def.id);
|
|
73
76
|
titleEl.textContent = pageTitle;
|
|
@@ -89,11 +92,10 @@ async function route() {
|
|
|
89
92
|
view.append(Spinner());
|
|
90
93
|
try {
|
|
91
94
|
const mod = await loadModule(def.module);
|
|
92
|
-
if (token !== renderToken) return;
|
|
95
|
+
if (token !== renderToken) return;
|
|
93
96
|
clear(view);
|
|
94
97
|
await mod.render(ctx);
|
|
95
|
-
|
|
96
|
-
// detail-page ctx.setTitle override).
|
|
98
|
+
removePrivateRepositoryLinks(view);
|
|
97
99
|
if (routeStatusEl) routeStatusEl.textContent = titleEl.textContent || t('nav.' + def.id);
|
|
98
100
|
} catch (err) {
|
|
99
101
|
if (token !== renderToken) return;
|
|
@@ -106,9 +108,6 @@ async function route() {
|
|
|
106
108
|
}
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
// Mobile/narrow-window nav: the sidebar is the only navigation, so below 900px it
|
|
110
|
-
// becomes an off-canvas drawer toggled from the topbar. Without this a phone/narrow
|
|
111
|
-
// window is stranded on whatever page loaded first.
|
|
112
111
|
function setupMobileNav() {
|
|
113
112
|
const toggle = document.getElementById('nav-toggle');
|
|
114
113
|
const scrim = document.getElementById('nav-scrim');
|
|
@@ -126,14 +125,10 @@ function setupMobileNav() {
|
|
|
126
125
|
}
|
|
127
126
|
|
|
128
127
|
function initTheme() {
|
|
129
|
-
// Read new key, falling back to the legacy 'cf-theme' so existing users keep their choice.
|
|
130
128
|
const saved = localStorage.getItem('careermate-theme') || localStorage.getItem('cf-theme');
|
|
131
129
|
if (saved === 'light' || saved === 'dark') document.documentElement.dataset.theme = saved;
|
|
132
130
|
}
|
|
133
131
|
|
|
134
|
-
// Static shell strings live in index.html (not page-rendered), so set them from the
|
|
135
|
-
// catalog at boot + on locale change. foot-status belongs to the demo shim when the
|
|
136
|
-
// demo is running, so skip it there.
|
|
137
132
|
function localizeShell() {
|
|
138
133
|
const sub = document.querySelector('.sidebar__subtitle');
|
|
139
134
|
if (sub) sub.textContent = t('nav.subtitle');
|
|
@@ -141,36 +136,32 @@ function localizeShell() {
|
|
|
141
136
|
if (skip) skip.textContent = t('nav.skip');
|
|
142
137
|
const foot = document.getElementById('foot-status');
|
|
143
138
|
if (foot && !window.__CAREERMATE_DEMO__) foot.textContent = t('nav.footStatus');
|
|
144
|
-
// FAQ help link points at the marketing-site FAQ in the active locale.
|
|
145
139
|
const faq = document.getElementById('foot-faq');
|
|
146
140
|
if (faq) {
|
|
147
141
|
const en = document.documentElement.lang === 'en';
|
|
148
142
|
faq.href = en ? 'https://careermate.life/en/faq' : 'https://careermate.life/faq';
|
|
149
143
|
faq.title = en ? 'FAQ' : '자주 묻는 질문';
|
|
150
144
|
}
|
|
145
|
+
removePrivateRepositoryLinks();
|
|
151
146
|
}
|
|
152
147
|
|
|
153
148
|
async function boot() {
|
|
154
149
|
initTheme();
|
|
155
150
|
setupMobileNav();
|
|
156
151
|
localizeShell();
|
|
157
|
-
// Skip link: jump keyboard focus past the sidebar to the page content. A hash
|
|
158
|
-
// anchor would collide with the router, so move focus directly instead.
|
|
159
152
|
document.getElementById('skip-link')?.addEventListener('click', () => {
|
|
160
153
|
view.setAttribute('tabindex', '-1');
|
|
161
154
|
view.focus();
|
|
162
155
|
});
|
|
163
|
-
// Show data location in the footer (privacy: user can always see where data lives).
|
|
164
156
|
try {
|
|
165
157
|
const h = await get('/api/health');
|
|
166
158
|
document.getElementById('foot-path').textContent = h.data_dir;
|
|
167
159
|
document.getElementById('foot-path').title = h.data_dir;
|
|
168
160
|
} catch { /* ignore */ }
|
|
169
161
|
await refreshNav();
|
|
170
|
-
// 로케일 전환 시 내비게이션과 현재 라우트를 다시 그린다(전체 리로드 없이).
|
|
171
162
|
onLangChange(() => { localizeShell(); refreshNav(); route(); });
|
|
172
163
|
window.addEventListener('hashchange', route);
|
|
173
164
|
await route();
|
|
174
165
|
}
|
|
175
166
|
|
|
176
|
-
boot();
|
|
167
|
+
boot();
|
|
@@ -28,6 +28,7 @@ export default {
|
|
|
28
28
|
'documents.cover.primary': 'Primary',
|
|
29
29
|
'documents.cover.open': 'Open',
|
|
30
30
|
'documents.cover.copy': 'Copy',
|
|
31
|
+
'documents.cover.more': 'More',
|
|
31
32
|
'documents.cover.setPrimary': 'Set as primary',
|
|
32
33
|
'documents.cover.delete': 'Delete',
|
|
33
34
|
'documents.cover.noContent': 'No content yet.',
|
|
@@ -47,6 +47,9 @@ export default {
|
|
|
47
47
|
'home.pipeline.title': 'Application pipeline',
|
|
48
48
|
'home.pipeline.openBoard': 'Open board',
|
|
49
49
|
'home.pipeline.empty': 'No application stages yet. Set an application status on a posting and your pipeline fills in here.',
|
|
50
|
+
// small-sample fallback (< 3 applications): show the current stage as one line
|
|
51
|
+
'home.pipeline.small.item': '{{status}} · {{count}}',
|
|
52
|
+
'home.pipeline.small': '{{detail}} — your stage flow shows here as applications add up',
|
|
50
53
|
'home.pipeline.stage.applied': 'Applied',
|
|
51
54
|
'home.pipeline.stage.documentPassed': 'Screening passed',
|
|
52
55
|
'home.pipeline.stage.interview': 'Interview',
|
|
@@ -62,7 +65,11 @@ export default {
|
|
|
62
65
|
'home.actionLane.title': 'What to do next',
|
|
63
66
|
'home.actionLane.viewAll': 'View all',
|
|
64
67
|
'home.actionLane.interviewPrep': 'Interview prep',
|
|
65
|
-
'home.actionLane.
|
|
68
|
+
'home.actionLane.followupBadge': 'Follow up',
|
|
69
|
+
// per-type reason / next-action line
|
|
70
|
+
'home.actionLane.reason.interview': 'Time to start interview prep',
|
|
71
|
+
'home.actionLane.reason.deadline': 'Application deadline',
|
|
72
|
+
'home.actionLane.reason.followup': 'Applied {{n}}d ago · time to follow up',
|
|
66
73
|
'home.actionLane.empty': 'Nothing to handle right now. Try analyzing a new posting.',
|
|
67
74
|
'home.placeholder.dash': '—',
|
|
68
75
|
|
|
@@ -68,6 +68,8 @@ export default {
|
|
|
68
68
|
'jobs.detail.deleteMessage': "Delete the '{{company}} · {{position}}' posting? Its linked application records will be removed too.",
|
|
69
69
|
'jobs.detail.deleteConfirm': 'Delete',
|
|
70
70
|
'jobs.detail.deleted': 'Posting deleted.',
|
|
71
|
+
'jobs.detail.dangerZone': 'Danger zone',
|
|
72
|
+
'jobs.detail.dangerZoneHint': 'Deletes this posting along with its fit analysis, cover letters, interview prep, and application records. This cannot be undone.',
|
|
71
73
|
'jobs.detail.list': 'Postings',
|
|
72
74
|
'jobs.detail.statusLabel': 'Status',
|
|
73
75
|
'jobs.detail.openPosting': 'Original posting',
|
|
@@ -6,6 +6,7 @@ export default {
|
|
|
6
6
|
'nav.applications': 'Applications',
|
|
7
7
|
'nav.documents': 'Documents',
|
|
8
8
|
'nav.interview': 'Interview prep',
|
|
9
|
+
'nav.insights': 'Diagnostics & insights',
|
|
9
10
|
'nav.settings': 'Settings',
|
|
10
11
|
'nav.section.workspace': 'Workspace',
|
|
11
12
|
'nav.section.system': 'System',
|
|
@@ -28,6 +28,7 @@ export default {
|
|
|
28
28
|
'documents.cover.primary': '대표',
|
|
29
29
|
'documents.cover.open': '열기',
|
|
30
30
|
'documents.cover.copy': '복사',
|
|
31
|
+
'documents.cover.more': '더보기',
|
|
31
32
|
'documents.cover.setPrimary': '대표 지정',
|
|
32
33
|
'documents.cover.delete': '삭제',
|
|
33
34
|
'documents.cover.noContent': '아직 내용이 없습니다.',
|
|
@@ -38,6 +38,9 @@ export default {
|
|
|
38
38
|
'home.pipeline.title': '지원 파이프라인',
|
|
39
39
|
'home.pipeline.openBoard': '보드 열기',
|
|
40
40
|
'home.pipeline.empty': '아직 지원 단계가 없어요. 공고에 지원 상태를 표시하면 여기에 파이프라인이 채워져요.',
|
|
41
|
+
// small-sample fallback (< 3 applications): show the current stage as one line
|
|
42
|
+
'home.pipeline.small.item': '{{status}} {{count}}건',
|
|
43
|
+
'home.pipeline.small': '{{detail}} — 지원이 쌓이면 단계별 흐름이 여기 보여요',
|
|
41
44
|
'home.pipeline.stage.applied': '지원',
|
|
42
45
|
'home.pipeline.stage.documentPassed': '서류 합격',
|
|
43
46
|
'home.pipeline.stage.interview': '면접',
|
|
@@ -50,7 +53,11 @@ export default {
|
|
|
50
53
|
'home.actionLane.title': '이번에 할 일',
|
|
51
54
|
'home.actionLane.viewAll': '전체 보기',
|
|
52
55
|
'home.actionLane.interviewPrep': '면접 준비',
|
|
53
|
-
'home.actionLane.
|
|
56
|
+
'home.actionLane.followupBadge': '팔로업',
|
|
57
|
+
// per-type reason / next-action line
|
|
58
|
+
'home.actionLane.reason.interview': '면접 준비를 시작할 차례',
|
|
59
|
+
'home.actionLane.reason.deadline': '지원 마감',
|
|
60
|
+
'home.actionLane.reason.followup': '지원 {{n}}일 — 팔로업 적기',
|
|
54
61
|
'home.actionLane.empty': '지금 처리할 일이 없어요. 새 공고를 분석해 보세요.',
|
|
55
62
|
'home.placeholder.dash': '—',
|
|
56
63
|
|
|
@@ -68,6 +68,8 @@ export default {
|
|
|
68
68
|
'jobs.detail.deleteMessage': "'{{company}} · {{position}}' 공고를 삭제할까요? 연결된 지원 기록도 함께 사라집니다.",
|
|
69
69
|
'jobs.detail.deleteConfirm': '삭제',
|
|
70
70
|
'jobs.detail.deleted': '공고를 삭제했어요.',
|
|
71
|
+
'jobs.detail.dangerZone': '위험 구역',
|
|
72
|
+
'jobs.detail.dangerZoneHint': '이 공고와 연결된 적합도 분석·자기소개서·면접 자료·지원 기록이 함께 삭제됩니다. 되돌릴 수 없어요.',
|
|
71
73
|
'jobs.detail.list': '목록',
|
|
72
74
|
'jobs.detail.statusLabel': '상태',
|
|
73
75
|
'jobs.detail.openPosting': '공고 원문',
|
|
@@ -1,9 +1,9 @@
|
|
|
1
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
|
-
el, get, post, put, del, Card, Badge, Btn,
|
|
4
|
+
el, get, post, put, del, Card, Badge, Btn, SubmitBtn, EmptyState,
|
|
5
5
|
Field, Input, Textarea, Select, openModal, confirmDialog,
|
|
6
|
-
toastOk, toastError, copyText, fmtRelative, mount, meta, ExportMenu,
|
|
6
|
+
toastOk, toastError, copyText, fmtRelative, mount, meta, ExportMenu, Menu,
|
|
7
7
|
} from '/lib.js';
|
|
8
8
|
import { t } from '/i18n.js';
|
|
9
9
|
|
|
@@ -144,12 +144,16 @@ function CoverRow(cl, reload) {
|
|
|
144
144
|
cl.is_primary ? Badge('accent', t('documents.cover.primary')) : null,
|
|
145
145
|
);
|
|
146
146
|
|
|
147
|
+
// Two primary actions stay visible (열기·내보내기); the rest fold into an
|
|
148
|
+
// overflow menu so the row reads calmly instead of a wall of five buttons.
|
|
147
149
|
const actions = el('div', { class: 'flex gap-2 wrap' },
|
|
148
150
|
Btn(t('documents.cover.open'), { sm: true, icon: 'external', onClick: () => openCoverDetail(cl.id, reload) }),
|
|
149
|
-
Btn(t('documents.cover.copy'), { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(cl.current_content || ''), disabled: !cl.current_content }),
|
|
150
151
|
ExportMenu(`/api/export/cover-letter/${cl.id}`),
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
Menu(t('documents.cover.more'), [
|
|
153
|
+
cl.current_content ? { label: t('documents.cover.copy'), icon: 'copy', onClick: () => copyText(cl.current_content || '') } : null,
|
|
154
|
+
cl.is_primary ? null : { label: t('documents.cover.setPrimary'), icon: 'check', onClick: () => setPrimaryCover(cl.id, reload) },
|
|
155
|
+
{ label: t('documents.cover.delete'), icon: 'trash', danger: true, onClick: () => removeCover(cl, reload) },
|
|
156
|
+
], { sm: true, icon: null }),
|
|
153
157
|
);
|
|
154
158
|
|
|
155
159
|
return Card({
|
|
@@ -142,6 +142,14 @@ function Pipeline(breakdown) {
|
|
|
142
142
|
if (total === 0) {
|
|
143
143
|
body = el('p', { class: 'muted', style: { margin: 0 } },
|
|
144
144
|
t('home.pipeline.empty'));
|
|
145
|
+
} else if (total < 3) {
|
|
146
|
+
// Too few applications for a stacked bar to read as anything but noise —
|
|
147
|
+
// show the current stage(s) as one calm line until the pipeline fills in.
|
|
148
|
+
const detail = PIPE_ORDER.filter((st) => c(st) > 0)
|
|
149
|
+
.map((st) => t('home.pipeline.small.item', { status: t('status.' + st), count: c(st) }))
|
|
150
|
+
.join(' · ');
|
|
151
|
+
body = el('p', { class: 'muted', style: { margin: 0 } },
|
|
152
|
+
t('home.pipeline.small', { detail }));
|
|
145
153
|
} else {
|
|
146
154
|
const segs = PIPE_ORDER.filter((st) => c(st) > 0).map((st) =>
|
|
147
155
|
el('div', {
|
|
@@ -211,7 +219,7 @@ function ActionLane(s) {
|
|
|
211
219
|
push(it.job.id, ListRow({
|
|
212
220
|
leading: statusDot('document_passed'),
|
|
213
221
|
title: it.job.company,
|
|
214
|
-
sub:
|
|
222
|
+
sub: t('home.actionLane.reason.interview'),
|
|
215
223
|
trailing: el('span', { class: 'badge badge--accent' }, el('span', { class: 'dot' }), t('home.actionLane.interviewPrep')),
|
|
216
224
|
onClick: () => navigate(`/jobs/${it.job.id}`),
|
|
217
225
|
}));
|
|
@@ -231,7 +239,7 @@ function ActionLane(s) {
|
|
|
231
239
|
push(it.job.id, ListRow({
|
|
232
240
|
leading: statusDot(it.status),
|
|
233
241
|
title: it.job.company,
|
|
234
|
-
sub:
|
|
242
|
+
sub: t('home.actionLane.reason.deadline'),
|
|
235
243
|
trailing: ddayBadge(it.days_left),
|
|
236
244
|
onClick: () => navigate(`/jobs/${it.job.id}`),
|
|
237
245
|
}));
|
|
@@ -240,8 +248,8 @@ function ActionLane(s) {
|
|
|
240
248
|
push(it.job.id, ListRow({
|
|
241
249
|
leading: statusDot('applied'),
|
|
242
250
|
title: it.job.company,
|
|
243
|
-
sub:
|
|
244
|
-
trailing: Badge('accent', t('home.actionLane.
|
|
251
|
+
sub: t('home.actionLane.reason.followup', { n: it.days_since }),
|
|
252
|
+
trailing: Badge('accent', t('home.actionLane.followupBadge')),
|
|
245
253
|
onClick: () => navigate(`/jobs/${it.job.id}`),
|
|
246
254
|
}));
|
|
247
255
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { el, get, post, Card, Btn, icon, mount, toastOk, toastError } from '/lib.js';
|
|
2
|
+
import { getLang } from '/i18n.js';
|
|
3
|
+
|
|
4
|
+
const copy = {
|
|
5
|
+
ko: {
|
|
6
|
+
title: '진단·데이터 건강도',
|
|
7
|
+
subtitle: '로컬 데이터 상태, 모순, 출처 확인 대상과 경험 재사용 현황을 한곳에서 점검합니다.',
|
|
8
|
+
runtime: '실행 환경',
|
|
9
|
+
health: '데이터 건강도',
|
|
10
|
+
provenance: '사람 확인이 필요한 정보',
|
|
11
|
+
reuse: '경험·프로젝트 재사용 지도',
|
|
12
|
+
healthy: '정상',
|
|
13
|
+
failed: '확인 필요',
|
|
14
|
+
verify: '사람이 확인함',
|
|
15
|
+
empty: '확인할 문제가 없습니다.',
|
|
16
|
+
score: '건강도 점수',
|
|
17
|
+
times: '회 사용',
|
|
18
|
+
local: 'DB와 커리어 데이터는 이 컴퓨터에 저장됩니다.',
|
|
19
|
+
network: '업데이트 확인·설치 문서·채용 검색은 선택적으로 인터넷을 사용할 수 있습니다.',
|
|
20
|
+
},
|
|
21
|
+
en: {
|
|
22
|
+
title: 'Diagnostics & data health',
|
|
23
|
+
subtitle: 'Review local storage, contradictions, provenance, and experience reuse.',
|
|
24
|
+
runtime: 'Runtime checks',
|
|
25
|
+
health: 'Data health',
|
|
26
|
+
provenance: 'Needs human verification',
|
|
27
|
+
reuse: 'Experience & project reuse map',
|
|
28
|
+
healthy: 'Healthy',
|
|
29
|
+
failed: 'Needs attention',
|
|
30
|
+
verify: 'Mark human verified',
|
|
31
|
+
empty: 'No issues found.',
|
|
32
|
+
score: 'Health score',
|
|
33
|
+
times: 'uses',
|
|
34
|
+
local: 'Your database and career records are stored on this computer.',
|
|
35
|
+
network: 'Update checks, install docs, and job search may optionally use the internet.',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function text() {
|
|
40
|
+
return copy[getLang()] || copy.ko;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function severityBadge(severity) {
|
|
44
|
+
const label = severity === 'error' ? 'ERROR' : severity === 'warning' ? 'WARN' : 'INFO';
|
|
45
|
+
return el('span', { class: `badge badge--${severity === 'error' ? 'rejected' : severity === 'warning' ? 'planned' : 'draft'}` }, label);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function render(ctx) {
|
|
49
|
+
const c = text();
|
|
50
|
+
ctx.setTitle(c.title);
|
|
51
|
+
ctx.setActions([Btn(c.title, { icon: 'check', sm: true, variant: 'ghost', onClick: () => render(ctx) })]);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const diagnostics = await get('/api/diagnostics');
|
|
55
|
+
const checks = diagnostics.checks || [];
|
|
56
|
+
const report = diagnostics.data_health || { score: 0, issues: [], counts: {} };
|
|
57
|
+
const unverified = diagnostics.unverified_entities || [];
|
|
58
|
+
const reuseMap = diagnostics.reuse_map || [];
|
|
59
|
+
|
|
60
|
+
const localFirst = Card({
|
|
61
|
+
title: 'Local-first',
|
|
62
|
+
body: el('div', { class: 'stack-2' },
|
|
63
|
+
el('div', { class: 'callout callout--privacy' }, icon('lock'), el('div', {},
|
|
64
|
+
el('div', { class: 'callout__title' }, c.local),
|
|
65
|
+
el('div', { class: 'callout__body' }, c.network))),
|
|
66
|
+
),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const runtime = Card({
|
|
70
|
+
title: c.runtime,
|
|
71
|
+
body: el('div', { class: 'stack-2' }, ...checks.map((check) =>
|
|
72
|
+
el('div', { class: 'flex between gap-3' },
|
|
73
|
+
el('div', {},
|
|
74
|
+
el('div', { class: 'strong' }, check.label),
|
|
75
|
+
el('div', { class: 'muted text-sm tnum' }, check.detail || '—')),
|
|
76
|
+
el('span', { class: `badge badge--${check.ok ? 'final_passed' : 'rejected'}` }, check.ok ? c.healthy : c.failed),
|
|
77
|
+
))),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const issueList = report.issues?.length
|
|
81
|
+
? el('div', { class: 'stack-2' }, ...report.issues.map((issue) =>
|
|
82
|
+
el('div', { class: 'flex gap-3', style: { alignItems: 'flex-start' } },
|
|
83
|
+
severityBadge(issue.severity),
|
|
84
|
+
el('div', {},
|
|
85
|
+
el('div', { class: 'strong' }, issue.message),
|
|
86
|
+
el('div', { class: 'muted text-sm' }, `${issue.entity_type}${issue.entity_id ? ` · ${issue.entity_id}` : ''}`)),
|
|
87
|
+
)))
|
|
88
|
+
: el('p', { class: 'muted' }, c.empty);
|
|
89
|
+
|
|
90
|
+
const health = Card({
|
|
91
|
+
title: c.health,
|
|
92
|
+
actions: [el('span', { class: 'badge badge--final_passed tnum' }, `${c.score} ${report.score}`)],
|
|
93
|
+
body: issueList,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const reuse = Card({
|
|
97
|
+
title: c.reuse,
|
|
98
|
+
body: reuseMap.length
|
|
99
|
+
? el('div', { class: 'stack-2' }, ...reuseMap.map((item) =>
|
|
100
|
+
el('div', { class: 'flex between gap-3', style: { alignItems: 'flex-start' } },
|
|
101
|
+
el('div', {},
|
|
102
|
+
el('div', { class: 'strong' }, item.label),
|
|
103
|
+
el('div', { class: 'muted text-sm' },
|
|
104
|
+
item.cover_letters?.length
|
|
105
|
+
? item.cover_letters.map((letter) => [letter.company, letter.position, letter.title].filter(Boolean).join(' · ')).join(' / ')
|
|
106
|
+
: c.empty)),
|
|
107
|
+
el('span', { class: `badge badge--${item.reuse_count ? 'final_passed' : 'draft'} tnum` }, `${item.reuse_count} ${c.times}`),
|
|
108
|
+
)))
|
|
109
|
+
: el('p', { class: 'muted' }, c.empty),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
async function verify(item, button) {
|
|
113
|
+
try {
|
|
114
|
+
button.disabled = true;
|
|
115
|
+
await post('/api/provenance/verify', {
|
|
116
|
+
entity_type: item.entity_type,
|
|
117
|
+
entity_id: item.entity_id,
|
|
118
|
+
verified: true,
|
|
119
|
+
});
|
|
120
|
+
toastOk(c.verify);
|
|
121
|
+
await render(ctx);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
button.disabled = false;
|
|
124
|
+
toastError(error);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const provenance = Card({
|
|
129
|
+
title: c.provenance,
|
|
130
|
+
body: unverified.length
|
|
131
|
+
? el('div', { class: 'stack-2' }, ...unverified.map((item) => {
|
|
132
|
+
const button = Btn(c.verify, { icon: 'check', sm: true, variant: 'ghost' });
|
|
133
|
+
button.addEventListener('click', () => verify(item, button));
|
|
134
|
+
return el('div', { class: 'flex between gap-3' },
|
|
135
|
+
el('div', {},
|
|
136
|
+
el('div', { class: 'strong' }, `${item.entity_type} · ${item.entity_id}`),
|
|
137
|
+
el('div', { class: 'muted text-sm' }, item.source_excerpt || item.source)),
|
|
138
|
+
button,
|
|
139
|
+
);
|
|
140
|
+
}))
|
|
141
|
+
: el('p', { class: 'muted' }, c.empty),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
mount(ctx.view, el('div', { class: 'stack-4' },
|
|
145
|
+
el('p', { class: 'muted', style: { marginTop: 0 } }, c.subtitle),
|
|
146
|
+
localFirst,
|
|
147
|
+
runtime,
|
|
148
|
+
health,
|
|
149
|
+
reuse,
|
|
150
|
+
provenance,
|
|
151
|
+
));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
toastError(error);
|
|
154
|
+
mount(ctx.view, el('div', { class: 'card' }, el('div', { class: 'card__body' }, String(error))));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -314,11 +314,11 @@ async function renderDetail(ctx, jobId) {
|
|
|
314
314
|
]);
|
|
315
315
|
|
|
316
316
|
// --- Page head ------------------------------------------------------------
|
|
317
|
-
// 수정은 상단바(ctx.setActions)에만 둔다 — 여기엔 상태
|
|
317
|
+
// 수정은 상단바(ctx.setActions)에만 둔다 — 여기엔 상태 변경·원문만.
|
|
318
|
+
// 삭제는 위험 액션이라 페이지 최하단 '위험 구역'으로 강등한다(아래 DangerZone).
|
|
318
319
|
const headActions = el('div', { class: 'flex gap-2 center wrap' },
|
|
319
320
|
statusSelect,
|
|
320
321
|
job.url ? el('a', { class: 'btn btn--ghost btn--sm', href: job.url, attrs: { target: '_blank', rel: 'noopener noreferrer' } }, icon('external'), el('span', {}, t('jobs.detail.openPosting'))) : null,
|
|
321
|
-
delBtn,
|
|
322
322
|
);
|
|
323
323
|
|
|
324
324
|
const head = el('div', { class: 'page-head' },
|
|
@@ -344,10 +344,23 @@ async function renderDetail(ctx, jobId) {
|
|
|
344
344
|
FitCard(job.fit),
|
|
345
345
|
TimelineCard(job.timeline),
|
|
346
346
|
RelatedCard(job.related),
|
|
347
|
+
DangerZone(delBtn),
|
|
347
348
|
);
|
|
348
349
|
mount(ctx.view, wrap);
|
|
349
350
|
}
|
|
350
351
|
|
|
352
|
+
// Delete is destructive, so it sits at the very bottom in a quiet 위험 구역 row
|
|
353
|
+
// (not a card, per the design guide) instead of competing in the top-right header.
|
|
354
|
+
function DangerZone(delBtn) {
|
|
355
|
+
return el('div', { class: 'stack-2', style: { marginTop: '8px' } },
|
|
356
|
+
el('div', { class: 'divider' }),
|
|
357
|
+
el('div', { class: 'flex between center wrap gap-3' },
|
|
358
|
+
el('div', { style: { maxWidth: '440px' } },
|
|
359
|
+
el('div', { class: 'strong text-sm' }, t('jobs.detail.dangerZone')),
|
|
360
|
+
el('div', { class: 'muted text-sm', style: { lineHeight: '1.6' } }, t('jobs.detail.dangerZoneHint'))),
|
|
361
|
+
delBtn));
|
|
362
|
+
}
|
|
363
|
+
|
|
351
364
|
/* ----------------------------------------------------------- detail cards */
|
|
352
365
|
|
|
353
366
|
function PostingCard(job) {
|
|
@@ -534,7 +547,12 @@ function CoverLettersCard(coverLetters) {
|
|
|
534
547
|
icon('file', 'muted'),
|
|
535
548
|
el('div', {},
|
|
536
549
|
el('div', { class: 'strong' }, cl.title),
|
|
537
|
-
|
|
550
|
+
// Preview line: version count + last-updated date so the row isn't a bare
|
|
551
|
+
// title. (The latest version's note isn't in this response — listByJob maps
|
|
552
|
+
// without versions — so we fall back to the date per the design.)
|
|
553
|
+
el('div', { class: 'muted text-sm' },
|
|
554
|
+
t('jobs.cover.versions', { count: cl.version_count ?? 1 }),
|
|
555
|
+
cl.updated_at ? ` · ${fmtDate(cl.updated_at)}` : ''))),
|
|
538
556
|
icon('chevronRight', 'muted')))),
|
|
539
557
|
});
|
|
540
558
|
}
|
package/dist/public/styles.css
CHANGED
|
@@ -419,6 +419,9 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
419
419
|
.pipefunnel__value { font-size: 24px; font-weight: 700; letter-spacing: -.02em; margin-top: 5px; line-height: 1.1; }
|
|
420
420
|
.pipefunnel__conv { font-size: 12px; color: var(--text-tertiary); margin-top: 2px; }
|
|
421
421
|
@media (max-width: 560px){ .pipefunnel { grid-template-columns: repeat(2, 1fr); } }
|
|
422
|
+
/* Home KPI tiles (the only .grid--4) read better as a 2×2 on phones than a single
|
|
423
|
+
full-width column; overrides the ≤900px collapse for this narrow range. */
|
|
424
|
+
@media (max-width: 560px){ .grid--4 { grid-template-columns: repeat(2, 1fr); } }
|
|
422
425
|
|
|
423
426
|
/* ----------------------------------------------------------------- check row (onboarding)
|
|
424
427
|
The ONE place setup guidance lives. Outcome rows auto-detect completion. */
|