careermate 0.0.9 → 0.0.12
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 +3 -2
- package/INSTALL.md +6 -6
- package/LICENSE +1 -1
- package/README.md +18 -16
- package/dist/career-os/README.md +2 -2
- package/dist/doctor.mjs +17857 -6595
- package/dist/mcp.mjs +14545 -3839
- package/dist/migrate.mjs +14258 -3709
- package/dist/pdf.worker.min.mjs +4 -4
- package/dist/public/app.js +3 -18
- 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/en/settings.js +1 -6
- 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/i18n/ko/settings.js +1 -6
- package/dist/public/index.html +0 -1
- package/dist/public/lib.js +13 -4
- package/dist/public/pages/documents.js +27 -10
- package/dist/public/pages/home.js +15 -5
- package/dist/public/pages/insights.js +156 -0
- package/dist/public/pages/jobs.js +21 -3
- package/dist/public/pages/settings.js +13 -12
- package/dist/public/styles.css +33 -0
- package/dist/web.mjs +16890 -5492
- package/docs/FAQ.md +3 -4
- package/docs/LOCAL_FIRST.md +32 -0
- package/docs/MCP_TOOLS.md +6 -3
- package/docs/REVIEW_IMPLEMENTATION_PLAN.md +22 -0
- package/docs/career-os/README.md +2 -2
- package/package.json +7 -12
- package/packages/core/src/data-health.ts +161 -0
- package/packages/core/src/fit-explain.ts +63 -0
- package/packages/core/src/index.ts +4 -0
- package/packages/core/src/job-categories.data.ts +140 -0
- package/packages/core/src/job-categories.ts +106 -0
- package/packages/core/src/jobsearch.ts +374 -66
- package/packages/core/src/reuse-insights.ts +126 -0
- package/packages/db/src/connection.ts +46 -26
- package/packages/db/src/index.ts +3 -0
- package/packages/db/src/interview-retrospectives.ts +145 -0
- package/packages/db/src/job-search.ts +100 -0
- package/packages/db/src/provenance.ts +125 -0
- package/packages/db/src/repositories.ts +1 -0
- package/packages/mcp-tools/src/tools.ts +115 -11
- package/packages/prompts/src/system.ts +21 -64
- package/scripts/init.ts +2 -0
- 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
|
|
|
@@ -35,7 +35,7 @@ function renderNav(counts = {}) {
|
|
|
35
35
|
navEl.append(el('div', { class: 'nav__label' }, t('nav.section.workspace')));
|
|
36
36
|
const { page } = parseHash();
|
|
37
37
|
for (const r of ROUTES) {
|
|
38
|
-
if (r.id === '
|
|
38
|
+
if (r.id === 'insights') navEl.append(el('div', { class: 'nav__label' }, t('nav.section.system')));
|
|
39
39
|
const count = r.countKey ? counts[r.countKey] : null;
|
|
40
40
|
navEl.append(el('a', {
|
|
41
41
|
class: `nav__item${page === r.id ? ' is-active' : ''}`,
|
|
@@ -67,7 +67,6 @@ async function route() {
|
|
|
67
67
|
const { page, params } = parseHash();
|
|
68
68
|
const def = ROUTES.find((r) => r.id === page) || ROUTES[0];
|
|
69
69
|
|
|
70
|
-
// active nav state
|
|
71
70
|
navEl.querySelectorAll('.nav__item').forEach((n) => n.classList.toggle('is-active', n.getAttribute('href') === `#/${def.id}`));
|
|
72
71
|
const pageTitle = t('nav.' + def.id);
|
|
73
72
|
titleEl.textContent = pageTitle;
|
|
@@ -89,11 +88,9 @@ async function route() {
|
|
|
89
88
|
view.append(Spinner());
|
|
90
89
|
try {
|
|
91
90
|
const mod = await loadModule(def.module);
|
|
92
|
-
if (token !== renderToken) return;
|
|
91
|
+
if (token !== renderToken) return;
|
|
93
92
|
clear(view);
|
|
94
93
|
await mod.render(ctx);
|
|
95
|
-
// Announce just the new page name to screen readers (titleEl reflects any
|
|
96
|
-
// detail-page ctx.setTitle override).
|
|
97
94
|
if (routeStatusEl) routeStatusEl.textContent = titleEl.textContent || t('nav.' + def.id);
|
|
98
95
|
} catch (err) {
|
|
99
96
|
if (token !== renderToken) return;
|
|
@@ -106,9 +103,6 @@ async function route() {
|
|
|
106
103
|
}
|
|
107
104
|
}
|
|
108
105
|
|
|
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
106
|
function setupMobileNav() {
|
|
113
107
|
const toggle = document.getElementById('nav-toggle');
|
|
114
108
|
const scrim = document.getElementById('nav-scrim');
|
|
@@ -126,14 +120,10 @@ function setupMobileNav() {
|
|
|
126
120
|
}
|
|
127
121
|
|
|
128
122
|
function initTheme() {
|
|
129
|
-
// Read new key, falling back to the legacy 'cf-theme' so existing users keep their choice.
|
|
130
123
|
const saved = localStorage.getItem('careermate-theme') || localStorage.getItem('cf-theme');
|
|
131
124
|
if (saved === 'light' || saved === 'dark') document.documentElement.dataset.theme = saved;
|
|
132
125
|
}
|
|
133
126
|
|
|
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
127
|
function localizeShell() {
|
|
138
128
|
const sub = document.querySelector('.sidebar__subtitle');
|
|
139
129
|
if (sub) sub.textContent = t('nav.subtitle');
|
|
@@ -141,7 +131,6 @@ function localizeShell() {
|
|
|
141
131
|
if (skip) skip.textContent = t('nav.skip');
|
|
142
132
|
const foot = document.getElementById('foot-status');
|
|
143
133
|
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
134
|
const faq = document.getElementById('foot-faq');
|
|
146
135
|
if (faq) {
|
|
147
136
|
const en = document.documentElement.lang === 'en';
|
|
@@ -154,20 +143,16 @@ async function boot() {
|
|
|
154
143
|
initTheme();
|
|
155
144
|
setupMobileNav();
|
|
156
145
|
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
146
|
document.getElementById('skip-link')?.addEventListener('click', () => {
|
|
160
147
|
view.setAttribute('tabindex', '-1');
|
|
161
148
|
view.focus();
|
|
162
149
|
});
|
|
163
|
-
// Show data location in the footer (privacy: user can always see where data lives).
|
|
164
150
|
try {
|
|
165
151
|
const h = await get('/api/health');
|
|
166
152
|
document.getElementById('foot-path').textContent = h.data_dir;
|
|
167
153
|
document.getElementById('foot-path').title = h.data_dir;
|
|
168
154
|
} catch { /* ignore */ }
|
|
169
155
|
await refreshNav();
|
|
170
|
-
// 로케일 전환 시 내비게이션과 현재 라우트를 다시 그린다(전체 리로드 없이).
|
|
171
156
|
onLangChange(() => { localizeShell(); refreshNav(); route(); });
|
|
172
157
|
window.addEventListener('hashchange', route);
|
|
173
158
|
await route();
|
|
@@ -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',
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
export default {
|
|
3
3
|
// shared
|
|
4
4
|
'settings.copy': 'Copy',
|
|
5
|
-
'settings.confirm': 'OK',
|
|
6
5
|
'settings.cancel': 'Cancel',
|
|
7
6
|
'settings.copyPath': 'Copy path',
|
|
8
7
|
|
|
@@ -17,8 +16,6 @@ export default {
|
|
|
17
16
|
|
|
18
17
|
// connection
|
|
19
18
|
'settings.connection.title': 'Connection',
|
|
20
|
-
'settings.connection.shortcutDemoToast': "Here's a preview of how the shortcut works.",
|
|
21
|
-
'settings.connection.shortcutToast': 'Created a dashboard shortcut.',
|
|
22
19
|
'settings.connection.shortcutDemoTitle': 'Shortcut preview',
|
|
23
20
|
'settings.connection.shortcutTitle': 'Shortcut created',
|
|
24
21
|
'settings.connection.shortcutDemoCalloutTitle': "The demo doesn't create files",
|
|
@@ -123,8 +120,6 @@ export default {
|
|
|
123
120
|
|
|
124
121
|
// help / report a problem
|
|
125
122
|
'settings.support.title': 'Help & report a problem',
|
|
126
|
-
'settings.support.sub': 'Report bugs
|
|
127
|
-
'settings.support.report': 'Report a problem',
|
|
128
|
-
'settings.support.ask': 'Ask a question',
|
|
123
|
+
'settings.support.sub': 'Report bugs, questions, and feature requests by email. Do not include private career data in support messages.',
|
|
129
124
|
'settings.support.email': 'Email us',
|
|
130
125
|
};
|
|
@@ -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': '공고 원문',
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
export default {
|
|
3
3
|
// 공통
|
|
4
4
|
'settings.copy': '복사',
|
|
5
|
-
'settings.confirm': '확인',
|
|
6
5
|
'settings.cancel': '취소',
|
|
7
6
|
'settings.copyPath': '경로 복사',
|
|
8
7
|
|
|
@@ -17,8 +16,6 @@ export default {
|
|
|
17
16
|
|
|
18
17
|
// 연결 상태
|
|
19
18
|
'settings.connection.title': '연결 상태',
|
|
20
|
-
'settings.connection.shortcutDemoToast': '데모 바로가기 동작을 미리 보여드려요.',
|
|
21
|
-
'settings.connection.shortcutToast': '대시보드 바로가기를 만들었어요.',
|
|
22
19
|
'settings.connection.shortcutDemoTitle': '바로가기 미리보기',
|
|
23
20
|
'settings.connection.shortcutTitle': '바로가기 생성',
|
|
24
21
|
'settings.connection.shortcutDemoCalloutTitle': '데모에서는 파일을 만들지 않습니다',
|
|
@@ -123,8 +120,6 @@ export default {
|
|
|
123
120
|
|
|
124
121
|
// help / report a problem
|
|
125
122
|
'settings.support.title': '도움말·문제 신고',
|
|
126
|
-
'settings.support.sub': '
|
|
127
|
-
'settings.support.report': '문제 신고',
|
|
128
|
-
'settings.support.ask': '질문하기',
|
|
123
|
+
'settings.support.sub': '\ubc84\uadf8, \uc0ac\uc6a9\ubc95 \uc9c8\ubb38, \uae30\ub2a5 \uc694\uccad\uc740 \uc774\uba54\uc77c\ub85c \ubcf4\ub0b4 \uc8fc\uc138\uc694. \ubb38\uc758\uc5d0\ub294 \uac1c\uc778 \ucee4\ub9ac\uc5b4 \ub370\uc774\ud130\ub97c \ub123\uc9c0 \ub9c8\uc138\uc694.',
|
|
129
124
|
'settings.support.email': '이메일 문의',
|
|
130
125
|
};
|
package/dist/public/index.html
CHANGED
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
<div class="sidebar__foot">
|
|
29
29
|
<div><span class="dot"></span><span id="foot-status">로컬에서 실행 중</span></div>
|
|
30
30
|
<div id="foot-path" class="truncate" style="margin-top:4px"></div>
|
|
31
|
-
<a class="sidebar__foot-link" href="https://github.com/osntak/careermate" target="_blank" rel="noopener" title="GitHub에서 소스 보기"><svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg><span>GitHub</span></a>
|
|
32
31
|
<a class="sidebar__foot-link" id="foot-faq" href="https://careermate.life/faq" target="_blank" rel="noopener" title="자주 묻는 질문"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.82 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg><span>FAQ</span></a>
|
|
33
32
|
</div>
|
|
34
33
|
</aside>
|
package/dist/public/lib.js
CHANGED
|
@@ -411,6 +411,9 @@ export function Spinner() { return el('div', { class: 'view' }, el('div', { clas
|
|
|
411
411
|
|
|
412
412
|
/* ------------------------------------------------------------------ toast */
|
|
413
413
|
let toastRoot;
|
|
414
|
+
function clearToasts() {
|
|
415
|
+
if (toastRoot) clear(toastRoot);
|
|
416
|
+
}
|
|
414
417
|
export function toast(message, { title, type = 'default', timeout = 3800 } = {}) {
|
|
415
418
|
if (!toastRoot) { toastRoot = el('div', { class: 'toasts' }); document.body.append(toastRoot); }
|
|
416
419
|
const t = el('div', { class: `toast toast--${type}` },
|
|
@@ -436,22 +439,27 @@ function ensureModalRoot() {
|
|
|
436
439
|
}
|
|
437
440
|
export function closeModal() {
|
|
438
441
|
if (modalRoot) { modalRoot.classList.remove('is-open'); clear(modalRoot); }
|
|
442
|
+
delete document.body.dataset.modalIntent;
|
|
439
443
|
// Return focus to whatever opened the modal so keyboard users aren't dumped at the page top.
|
|
440
444
|
if (modalReturnFocus) { try { modalReturnFocus.focus(); } catch { /* element gone */ } modalReturnFocus = null; }
|
|
441
445
|
}
|
|
442
446
|
/**
|
|
443
447
|
* Open a modal. `render(close)` returns the modal body content. Provide title,
|
|
444
|
-
* optional footer buttons via `footer(close)`.
|
|
448
|
+
* optional footer buttons via `footer(close)`. `intent` only changes
|
|
449
|
+
* interruption weight; pages should not hand-roll dialog styles.
|
|
445
450
|
*/
|
|
446
|
-
export function openModal({ title, body, footer, size }) {
|
|
451
|
+
export function openModal({ title, body, footer, size, intent = 'dialog' }) {
|
|
447
452
|
const root = ensureModalRoot();
|
|
448
453
|
const close = closeModal;
|
|
454
|
+
if (intent === 'contextual' || intent === 'alert') clearToasts();
|
|
455
|
+
root.dataset.intent = intent;
|
|
456
|
+
document.body.dataset.modalIntent = intent;
|
|
449
457
|
modalReturnFocus = document.activeElement; // restored by closeModal
|
|
450
458
|
const closeBtn = IconBtn('plus', { title: t('common.close'), onClick: close }); // rotated to look like ×
|
|
451
459
|
closeBtn.style.transform = 'rotate(45deg)';
|
|
452
460
|
const modal = el('div', {
|
|
453
|
-
class: `modal${size === 'lg' ? ' modal--lg' : ''}`,
|
|
454
|
-
attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'modal-title' },
|
|
461
|
+
class: `modal${size === 'lg' ? ' modal--lg' : ''}${intent === 'alert' ? ' modal--alert' : ''}`,
|
|
462
|
+
attrs: { role: intent === 'alert' ? 'alertdialog' : 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'modal-title' },
|
|
455
463
|
},
|
|
456
464
|
el('div', { class: 'modal__head' }, el('h3', { id: 'modal-title' }, title), el('div', { class: 'topbar__spacer' }), closeBtn),
|
|
457
465
|
);
|
|
@@ -482,6 +490,7 @@ export function confirmDialog({ title = t('common.confirm'), message, confirmLab
|
|
|
482
490
|
return new Promise((resolve) => {
|
|
483
491
|
openModal({
|
|
484
492
|
title,
|
|
493
|
+
intent: danger ? 'alert' : 'dialog',
|
|
485
494
|
body: el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } }, message),
|
|
486
495
|
footer: (close) => [
|
|
487
496
|
Btn(t('common.cancel'), { onClick: () => { close(); resolve(false); } }),
|
|
@@ -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
|
|
|
@@ -21,6 +21,7 @@ export async function render(ctx) {
|
|
|
21
21
|
const root = el('div', { class: 'stack-4' });
|
|
22
22
|
|
|
23
23
|
const panel = el('div', {});
|
|
24
|
+
let activeHasItems = true;
|
|
24
25
|
// Tabs are keyboard-operable (activate = role/tabindex/Enter·Space), with tab
|
|
25
26
|
// semantics for AT — role은 attrs가 먼저 tab으로 지정하므로 activate의 button 기본값을 덮지 않는다.
|
|
26
27
|
const makeTab = (key, label) =>
|
|
@@ -43,7 +44,7 @@ export async function render(ctx) {
|
|
|
43
44
|
node.classList.toggle('is-active', tab === key);
|
|
44
45
|
node.setAttribute('aria-selected', String(tab === key));
|
|
45
46
|
}
|
|
46
|
-
setActions();
|
|
47
|
+
ctx.setActions([]);
|
|
47
48
|
renderPanel();
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -74,9 +75,14 @@ export async function render(ctx) {
|
|
|
74
75
|
async function renderPanel() {
|
|
75
76
|
mount(panel, el('div', { class: 'muted text-sm' }, t('documents.loading')));
|
|
76
77
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
const content = tab === 'cover'
|
|
79
|
+
? await CoverLettersTab(reload)
|
|
80
|
+
: tab === 'career'
|
|
81
|
+
? await CareerDescriptionsTab(reload)
|
|
82
|
+
: await DocumentsTab(reload);
|
|
83
|
+
activeHasItems = content.dataset.hasItems === 'true';
|
|
84
|
+
mount(panel, content);
|
|
85
|
+
setActions();
|
|
80
86
|
applyFilter();
|
|
81
87
|
await openRequestedDocument();
|
|
82
88
|
} catch (err) {
|
|
@@ -102,6 +108,10 @@ export async function render(ctx) {
|
|
|
102
108
|
|
|
103
109
|
// topbar: search (filters the active tab) + the tab's create action
|
|
104
110
|
function setActions() {
|
|
111
|
+
if (!activeHasItems) {
|
|
112
|
+
ctx.setActions([]);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
105
115
|
const placeholder = tab === 'cover' ? t('documents.search.cover') : tab === 'career' ? t('documents.search.career') : t('documents.search.docs');
|
|
106
116
|
const search = Input({ type: 'search', placeholder, value: query, attrs: { 'aria-label': t('documents.search.aria') } });
|
|
107
117
|
search.classList.add('input--inline');
|
|
@@ -115,7 +125,7 @@ export async function render(ctx) {
|
|
|
115
125
|
}
|
|
116
126
|
|
|
117
127
|
mount(ctx.view, root);
|
|
118
|
-
setActions();
|
|
128
|
+
ctx.setActions([]);
|
|
119
129
|
await renderPanel();
|
|
120
130
|
}
|
|
121
131
|
|
|
@@ -124,6 +134,7 @@ export async function render(ctx) {
|
|
|
124
134
|
async function CoverLettersTab(reload) {
|
|
125
135
|
const { cover_letters: list } = await get('/api/cover-letters');
|
|
126
136
|
const wrap = el('div', { class: 'stack-3' });
|
|
137
|
+
wrap.dataset.hasItems = list.length ? 'true' : 'false';
|
|
127
138
|
|
|
128
139
|
if (!list.length) {
|
|
129
140
|
return mountInto(wrap, EmptyState({
|
|
@@ -144,12 +155,16 @@ function CoverRow(cl, reload) {
|
|
|
144
155
|
cl.is_primary ? Badge('accent', t('documents.cover.primary')) : null,
|
|
145
156
|
);
|
|
146
157
|
|
|
158
|
+
// Two primary actions stay visible (열기·내보내기); the rest fold into an
|
|
159
|
+
// overflow menu so the row reads calmly instead of a wall of five buttons.
|
|
147
160
|
const actions = el('div', { class: 'flex gap-2 wrap' },
|
|
148
161
|
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
162
|
ExportMenu(`/api/export/cover-letter/${cl.id}`),
|
|
151
|
-
|
|
152
|
-
|
|
163
|
+
Menu(t('documents.cover.more'), [
|
|
164
|
+
cl.current_content ? { label: t('documents.cover.copy'), icon: 'copy', onClick: () => copyText(cl.current_content || '') } : null,
|
|
165
|
+
cl.is_primary ? null : { label: t('documents.cover.setPrimary'), icon: 'check', onClick: () => setPrimaryCover(cl.id, reload) },
|
|
166
|
+
{ label: t('documents.cover.delete'), icon: 'trash', danger: true, onClick: () => removeCover(cl, reload) },
|
|
167
|
+
], { sm: true, icon: null }),
|
|
153
168
|
);
|
|
154
169
|
|
|
155
170
|
return Card({
|
|
@@ -372,6 +387,7 @@ async function CareerDescriptionsTab(reload) {
|
|
|
372
387
|
const [{ documents: list }, m] = await Promise.all([get('/api/documents?kind=career_description'), meta()]);
|
|
373
388
|
const kindLabels = Object.fromEntries((m.document_kinds || []).map((k) => [k.value, t('kind.' + k.value)]));
|
|
374
389
|
const wrap = el('div', { class: 'stack-3' });
|
|
390
|
+
wrap.dataset.hasItems = list.length ? 'true' : 'false';
|
|
375
391
|
|
|
376
392
|
if (!list.length) {
|
|
377
393
|
return mountInto(wrap, EmptyState({
|
|
@@ -393,6 +409,7 @@ async function DocumentsTab(reload) {
|
|
|
393
409
|
const list = (documents || []).filter((doc) => doc.kind !== 'career_description');
|
|
394
410
|
const kindLabels = Object.fromEntries((m.document_kinds || []).map((k) => [k.value, t('kind.' + k.value)]));
|
|
395
411
|
const wrap = el('div', { class: 'stack-3' });
|
|
412
|
+
wrap.dataset.hasItems = list.length ? 'true' : 'false';
|
|
396
413
|
|
|
397
414
|
if (!list.length) {
|
|
398
415
|
return mountInto(wrap, EmptyState({
|
|
@@ -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
|
}
|
|
@@ -254,13 +262,15 @@ function ActionLane(s) {
|
|
|
254
262
|
onClick: it.job ? () => navigate(`/jobs/${it.job.id}`) : null,
|
|
255
263
|
}));
|
|
256
264
|
}
|
|
257
|
-
|
|
265
|
+
const card = Card({
|
|
258
266
|
title: t('home.actionLane.title'),
|
|
259
267
|
actions: rows.length ? Btn(t('home.actionLane.viewAll'), { sm: true, variant: 'ghost', onClick: () => navigate('/applications') }) : null,
|
|
260
268
|
body: rows.length
|
|
261
269
|
? el('div', {}, ...rows)
|
|
262
270
|
: el('p', { class: 'muted', style: { margin: 0 } }, t('home.actionLane.empty')),
|
|
263
271
|
});
|
|
272
|
+
card.classList.add('home-action-card');
|
|
273
|
+
return card;
|
|
264
274
|
}
|
|
265
275
|
|
|
266
276
|
/* ----------------------------------------------- recent jobs */
|
|
@@ -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
|
+
}
|