careermate 0.1.0 → 0.1.2
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/README.md +22 -17
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/apps/mcp/src/index.ts +4 -3
- package/apps/web/DESIGN_GUIDE.md +2 -2
- package/apps/web/demo/demo-shim.js +536 -0
- package/apps/web/demo/demo.css +75 -0
- package/apps/web/demo/seed.js +269 -0
- package/apps/web/public/app.js +34 -2
- package/apps/web/public/icon-180.png +0 -0
- package/apps/web/public/index.html +14 -3
- package/apps/web/public/lib.js +135 -23
- package/apps/web/public/pages/applications.js +18 -9
- package/apps/web/public/pages/documents.js +66 -50
- package/apps/web/public/pages/home.js +12 -11
- package/apps/web/public/pages/interview.js +9 -12
- package/apps/web/public/pages/jobs.js +39 -36
- package/apps/web/public/pages/profile.js +66 -86
- package/apps/web/public/pages/settings.js +21 -14
- package/apps/web/public/styles.css +105 -38
- package/apps/web/src/http.ts +31 -4
- package/apps/web/src/info.ts +9 -22
- package/apps/web/src/routes.ts +19 -14
- package/apps/web/src/server.ts +34 -29
- package/apps/web/src/settings.ts +35 -17
- package/bin/careermate.mjs +8 -5
- package/dist/doctor.mjs +78908 -0
- package/dist/install-page/.omc/state/agent-replay-55a58c0d-bb6e-43ea-8a05-4b6c93b80b34.jsonl +12 -0
- package/dist/install-page/.omc/state/idle-notif-cooldown.json +3 -0
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/mission-state.json +147 -0
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/pre-tool-advisory-throttle.json +22 -0
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/subagent-tracking-state.json +44 -0
- package/dist/install-page/favicon.svg +1 -0
- package/dist/install-page/icon-180.png +0 -0
- package/dist/install-page/icon-512.png +0 -0
- package/dist/install-page/index.html +328 -225
- package/dist/install-page/landing.js +54 -0
- package/dist/install-page/llms-install.txt +202 -0
- package/dist/install-page/og.png +0 -0
- package/dist/install-page/shots/applications-dark.png +0 -0
- package/dist/install-page/shots/applications-light.png +0 -0
- package/dist/install-page/shots/documents-dark.png +0 -0
- package/dist/install-page/shots/documents-light.png +0 -0
- package/dist/install-page/shots/home-dark.png +0 -0
- package/dist/install-page/shots/home-light.png +0 -0
- package/dist/install-page/shots/interview-dark.png +0 -0
- package/dist/install-page/shots/interview-light.png +0 -0
- package/dist/install-page/shots/jobs-dark.png +0 -0
- package/dist/install-page/shots/jobs-light.png +0 -0
- package/dist/install-page/shots/settings-dark.png +0 -0
- package/dist/install-page/shots/settings-light.png +0 -0
- package/dist/install-page/start.html +323 -0
- package/dist/install-page/style.css +128 -4
- package/dist/mcp.mjs +74225 -2007
- package/dist/{mcpb-stage/dist/web.mjs → migrate.mjs} +142 -2660
- package/dist/pdf.worker.min.mjs +28 -0
- package/dist/public/app.js +34 -2
- package/dist/public/icon-180.png +0 -0
- package/dist/public/index.html +14 -3
- package/dist/public/lib.js +135 -23
- package/dist/public/pages/applications.js +18 -9
- package/dist/public/pages/documents.js +66 -50
- package/dist/public/pages/home.js +12 -11
- package/dist/public/pages/interview.js +9 -12
- package/dist/public/pages/jobs.js +39 -36
- package/dist/public/pages/profile.js +66 -86
- package/dist/public/pages/settings.js +21 -14
- package/dist/public/styles.css +105 -38
- package/dist/web.mjs +39950 -369
- package/docs/ARCHITECTURE.md +5 -5
- package/docs/DECISIONS.md +1 -1
- package/docs/DEMO.md +6 -6
- package/docs/MCP_TOOLS.md +68 -9
- package/docs/ROADMAP.md +11 -10
- package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +135 -0
- package/docs/START_WORKFLOW.md +19 -7
- package/docs/SUPPORTED_AI_APPS.md +85 -33
- package/docs/TODO.md +1 -1
- package/docs/WORKFLOWS.md +8 -6
- package/docs/superpowers/specs/2026-06-16-demo-dashboard-deploy-design.md +64 -0
- package/package.json +8 -3
- package/packages/core/src/services.ts +42 -1
- package/packages/db/src/connection.ts +30 -0
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/paths.ts +11 -0
- package/packages/db/src/repositories.ts +109 -63
- package/packages/db/src/schema.ts +118 -0
- package/packages/exporters/src/html.ts +9 -12
- package/packages/mcp-tools/src/bridge.ts +55 -32
- package/packages/mcp-tools/src/index.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +269 -65
- package/packages/mcp-tools/src/update.ts +330 -0
- package/packages/parsers/src/documents.ts +521 -0
- package/packages/parsers/src/index.ts +8 -5
- package/packages/prompts/src/onboarding.ts +2 -2
- package/packages/shared/src/build-info.ts +22 -0
- package/packages/shared/src/enums.ts +39 -0
- package/packages/shared/src/index.ts +1 -0
- package/packages/shared/src/schemas.ts +24 -3
- package/packages/workflows/src/definitions.ts +4 -2
- package/scripts/build-demo.mjs +130 -0
- package/scripts/build-dist.mjs +46 -8
- package/scripts/build-mcpb.mjs +60 -4
- package/scripts/doctor.ts +16 -3
- package/scripts/gen-og.mjs +86 -0
- package/scripts/init.ts +32 -3
- package/scripts/seed.ts +5 -5
- package/scripts/shots-seed.ts +192 -0
- package/scripts/shots.ts +89 -0
- package/tsconfig.json +3 -1
- package/dist/careermate.mcpb +0 -0
- package/dist/mcp-smoke.err +0 -3
- package/dist/mcpb-stage/README.md +0 -219
- package/dist/mcpb-stage/dist/install-page/index.html +0 -434
- package/dist/mcpb-stage/dist/install-page/style.css +0 -407
- package/dist/mcpb-stage/dist/install-page/vercel.json +0 -20
- package/dist/mcpb-stage/dist/mcp.mjs +0 -23704
- package/dist/mcpb-stage/dist/public/app.js +0 -118
- package/dist/mcpb-stage/dist/public/index.html +0 -41
- package/dist/mcpb-stage/dist/public/lib.js +0 -282
- package/dist/mcpb-stage/dist/public/pages/applications.js +0 -98
- package/dist/mcpb-stage/dist/public/pages/documents.js +0 -446
- package/dist/mcpb-stage/dist/public/pages/home.js +0 -263
- package/dist/mcpb-stage/dist/public/pages/interview.js +0 -230
- package/dist/mcpb-stage/dist/public/pages/jobs.js +0 -494
- package/dist/mcpb-stage/dist/public/pages/profile.js +0 -576
- package/dist/mcpb-stage/dist/public/pages/settings.js +0 -233
- package/dist/mcpb-stage/dist/public/styles.css +0 -420
- package/dist/mcpb-stage/manifest.json +0 -40
- package/docs/INSTALL.md +0 -148
- package/docs/INSTALL_AND_USAGE.md +0 -99
- package/install-page/index.html +0 -474
- package/install-page/style.css +0 -391
- package/install-page/vercel.json +0 -20
- /package/dist/{mcpb-stage/dist/public → install-page}/fonts/PretendardVariable.woff2 +0 -0
package/dist/public/app.js
CHANGED
|
@@ -19,6 +19,7 @@ const view = document.getElementById('view');
|
|
|
19
19
|
const navEl = document.getElementById('nav');
|
|
20
20
|
const titleEl = document.getElementById('topbar-title');
|
|
21
21
|
const actionsEl = document.getElementById('topbar-actions');
|
|
22
|
+
const routeStatusEl = document.getElementById('route-status');
|
|
22
23
|
const moduleCache = new Map();
|
|
23
24
|
|
|
24
25
|
function parseHash() {
|
|
@@ -87,23 +88,54 @@ async function route() {
|
|
|
87
88
|
if (token !== renderToken) return; // a newer navigation superseded this one
|
|
88
89
|
clear(view);
|
|
89
90
|
await mod.render(ctx);
|
|
91
|
+
// Announce just the new page name to screen readers (titleEl reflects any
|
|
92
|
+
// detail-page ctx.setTitle override).
|
|
93
|
+
if (routeStatusEl) routeStatusEl.textContent = titleEl.textContent || def.label;
|
|
90
94
|
} catch (err) {
|
|
91
95
|
if (token !== renderToken) return;
|
|
92
96
|
clear(view);
|
|
93
97
|
view.append(el('div', { class: 'card' }, el('div', { class: 'card__body' },
|
|
94
98
|
el('h3', { style: { marginBottom: '8px' } }, '페이지를 불러오지 못했습니다'),
|
|
95
|
-
el('p', { class: 'text-secondary' }, err instanceof Error ? err.message : String(err))
|
|
99
|
+
el('p', { class: 'text-secondary', style: { marginBottom: '14px' } }, err instanceof Error ? err.message : String(err)),
|
|
100
|
+
el('button', { class: 'btn btn--primary', type: 'button', onClick: () => route() }, '다시 시도'))));
|
|
96
101
|
toastError(err);
|
|
97
102
|
}
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
// Mobile/narrow-window nav: the sidebar is the only navigation, so below 900px it
|
|
106
|
+
// becomes an off-canvas drawer toggled from the topbar. Without this a phone/narrow
|
|
107
|
+
// window is stranded on whatever page loaded first.
|
|
108
|
+
function setupMobileNav() {
|
|
109
|
+
const toggle = document.getElementById('nav-toggle');
|
|
110
|
+
const scrim = document.getElementById('nav-scrim');
|
|
111
|
+
const app = document.querySelector('.app');
|
|
112
|
+
if (!toggle || !app) return;
|
|
113
|
+
const setOpen = (open) => {
|
|
114
|
+
app.classList.toggle('nav-open', open);
|
|
115
|
+
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
116
|
+
toggle.setAttribute('aria-label', open ? '메뉴 닫기' : '메뉴 열기');
|
|
117
|
+
};
|
|
118
|
+
toggle.addEventListener('click', () => setOpen(!app.classList.contains('nav-open')));
|
|
119
|
+
scrim?.addEventListener('click', () => setOpen(false));
|
|
120
|
+
navEl.addEventListener('click', (e) => { if (e.target.closest('.nav__item')) setOpen(false); });
|
|
121
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && app.classList.contains('nav-open')) setOpen(false); });
|
|
122
|
+
}
|
|
123
|
+
|
|
100
124
|
function initTheme() {
|
|
101
|
-
|
|
125
|
+
// Read new key, falling back to the legacy 'cf-theme' so existing users keep their choice.
|
|
126
|
+
const saved = localStorage.getItem('careermate-theme') || localStorage.getItem('cf-theme');
|
|
102
127
|
if (saved === 'light' || saved === 'dark') document.documentElement.dataset.theme = saved;
|
|
103
128
|
}
|
|
104
129
|
|
|
105
130
|
async function boot() {
|
|
106
131
|
initTheme();
|
|
132
|
+
setupMobileNav();
|
|
133
|
+
// Skip link: jump keyboard focus past the sidebar to the page content. A hash
|
|
134
|
+
// anchor would collide with the router, so move focus directly instead.
|
|
135
|
+
document.getElementById('skip-link')?.addEventListener('click', () => {
|
|
136
|
+
view.setAttribute('tabindex', '-1');
|
|
137
|
+
view.focus();
|
|
138
|
+
});
|
|
107
139
|
// Show data location in the footer (privacy: user can always see where data lives).
|
|
108
140
|
try {
|
|
109
141
|
const h = await get('/api/health');
|
|
Binary file
|
package/dist/public/index.html
CHANGED
|
@@ -5,13 +5,18 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>CareerMate — 내 커리어 흐름 관리</title>
|
|
7
7
|
<meta name="description" content="ChatGPT/Claude와 함께 쓰는 로컬 커리어 관리 도구" />
|
|
8
|
-
<
|
|
8
|
+
<meta name="theme-color" content="#faf8f4" media="(prefers-color-scheme: light)" />
|
|
9
|
+
<meta name="theme-color" content="#14130f" media="(prefers-color-scheme: dark)" />
|
|
10
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><rect width='24' height='24' rx='6' fill='%230f7256'/><text x='12' y='17' font-size='14' text-anchor='middle' fill='white' font-family='sans-serif' font-weight='bold'>C</text></svg>" />
|
|
11
|
+
<link rel="apple-touch-icon" href="/icon-180.png" />
|
|
9
12
|
<link rel="preload" href="/fonts/PretendardVariable.woff2" as="font" type="font/woff2" crossorigin />
|
|
10
13
|
<link rel="stylesheet" href="/styles.css" />
|
|
11
14
|
</head>
|
|
12
15
|
<body>
|
|
16
|
+
<button class="skip-link" id="skip-link" type="button">본문으로 건너뛰기</button>
|
|
13
17
|
<div class="app">
|
|
14
|
-
<
|
|
18
|
+
<div class="nav-scrim" id="nav-scrim"></div>
|
|
19
|
+
<aside class="sidebar" id="sidebar">
|
|
15
20
|
<div class="sidebar__brand">
|
|
16
21
|
<div class="sidebar__logo">C</div>
|
|
17
22
|
<div>
|
|
@@ -28,12 +33,18 @@
|
|
|
28
33
|
|
|
29
34
|
<div class="main">
|
|
30
35
|
<header class="topbar">
|
|
36
|
+
<button class="nav-toggle" id="nav-toggle" type="button" aria-label="메뉴 열기" aria-expanded="false" aria-controls="sidebar">
|
|
37
|
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
|
38
|
+
</button>
|
|
31
39
|
<div class="topbar__title" id="topbar-title">CareerMate</div>
|
|
32
40
|
<div class="topbar__spacer"></div>
|
|
33
41
|
<div id="topbar-actions" class="flex gap-2"></div>
|
|
34
42
|
</header>
|
|
35
|
-
<main id="view" class="view"
|
|
43
|
+
<main id="view" class="view"></main>
|
|
36
44
|
</div>
|
|
45
|
+
<!-- Concise route announcer: speaks only the new page name, not the whole view
|
|
46
|
+
(the old aria-live on #view re-read the entire page on every navigation). -->
|
|
47
|
+
<div id="route-status" class="sr-only" role="status" aria-live="polite"></div>
|
|
37
48
|
</div>
|
|
38
49
|
|
|
39
50
|
<script type="module" src="/app.js"></script>
|
package/dist/public/lib.js
CHANGED
|
@@ -10,6 +10,19 @@
|
|
|
10
10
|
|
|
11
11
|
/* --------------------------------------------------------------- DOM helper */
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Neutralize script-capable URL schemes before they reach an href/src. Career
|
|
15
|
+
* data (profile links, job URLs) is user-controlled and can arrive via the MCP
|
|
16
|
+
* path with no CSRF gate, so a stored `javascript:…` link would execute when
|
|
17
|
+
* clicked. http(s)/mailto and scheme-less/relative URLs pass through unchanged;
|
|
18
|
+
* any other explicit scheme is replaced with a harmless '#'.
|
|
19
|
+
*/
|
|
20
|
+
export function safeUrl(v) {
|
|
21
|
+
const s = String(v).trim();
|
|
22
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return /^(https?:|mailto:)/i.test(s) ? s : '#';
|
|
23
|
+
return s;
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
/**
|
|
14
27
|
* Create an element. `props` may include: class, id, type, value, placeholder,
|
|
15
28
|
* href, title, disabled, dataset:{}, style:{}, attrs:{}, and on* event handlers
|
|
@@ -27,7 +40,19 @@ export function el(tag, props = {}, ...children) {
|
|
|
27
40
|
else if (k === 'dataset') Object.assign(node.dataset, v);
|
|
28
41
|
else if (k === 'style') Object.assign(node.style, v);
|
|
29
42
|
else if (k === 'attrs') for (const [a, val] of Object.entries(v)) node.setAttribute(a, val);
|
|
43
|
+
else if (k === 'href' || k === 'src') node.setAttribute(k, safeUrl(v)); // block javascript:/data: URLs
|
|
30
44
|
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
|
|
45
|
+
else if (k === 'activate' && typeof v === 'function') {
|
|
46
|
+
// Make a non-native element behave like a button for keyboard + AT users:
|
|
47
|
+
// role, focusability, and Enter/Space activation. Use this instead of a bare
|
|
48
|
+
// onClick on clickable <div>/<span> so they aren't mouse-only dead zones.
|
|
49
|
+
if (!node.getAttribute('role')) node.setAttribute('role', 'button');
|
|
50
|
+
if (node.getAttribute('tabindex') == null) node.setAttribute('tabindex', '0');
|
|
51
|
+
node.addEventListener('click', v);
|
|
52
|
+
node.addEventListener('keydown', (e) => {
|
|
53
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); v(e); }
|
|
54
|
+
});
|
|
55
|
+
}
|
|
31
56
|
else if (k in node) { try { node[k] = v; } catch { node.setAttribute(k, v); } }
|
|
32
57
|
else node.setAttribute(k, v);
|
|
33
58
|
}
|
|
@@ -40,7 +65,6 @@ function appendChildren(node, children) {
|
|
|
40
65
|
node.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
|
41
66
|
}
|
|
42
67
|
}
|
|
43
|
-
export function frag(...children) { const f = document.createDocumentFragment(); appendChildren(f, children); return f; }
|
|
44
68
|
export function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); return node; }
|
|
45
69
|
export function mount(node, ...children) { clear(node); appendChildren(node, children); return node; }
|
|
46
70
|
|
|
@@ -49,7 +73,7 @@ export function mount(node, ...children) { clear(node); appendChildren(node, chi
|
|
|
49
73
|
const ICONS = {
|
|
50
74
|
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>',
|
|
51
75
|
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/>',
|
|
52
|
-
briefcase: '<rect x="3" y="
|
|
76
|
+
briefcase: '<rect x="3" y="8" width="18" height="11" rx="2.5"/><path d="M8.5 8V6.5a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2V8"/><path d="M3 13h18"/>',
|
|
53
77
|
layers: '<path d="m12 3 9 5-9 5-9-5 9-5Z"/><path d="m3 13 9 5 9-5"/>',
|
|
54
78
|
file: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"/><path d="M14 3v5h5"/>',
|
|
55
79
|
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/>',
|
|
@@ -94,19 +118,33 @@ const TOKEN = document.querySelector('meta[name="careermate-token"]')?.content |
|
|
|
94
118
|
|
|
95
119
|
/** Call the JSON API. Throws Error(message) on non-2xx. */
|
|
96
120
|
export async function api(method, path, body) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
121
|
+
let res;
|
|
122
|
+
try {
|
|
123
|
+
res = await fetch(path, {
|
|
124
|
+
method,
|
|
125
|
+
headers: {
|
|
126
|
+
'Content-Type': 'application/json',
|
|
127
|
+
...(method !== 'GET' ? { 'x-careermate-token': TOKEN } : {}),
|
|
128
|
+
},
|
|
129
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
130
|
+
});
|
|
131
|
+
} catch {
|
|
132
|
+
// Connection failure (server stopped, machine asleep) — never surface "Failed to fetch".
|
|
133
|
+
throw new Error('서버에 연결하지 못했어요. 대시보드가 실행 중인지 확인한 뒤 다시 시도해 주세요.');
|
|
134
|
+
}
|
|
105
135
|
const text = await res.text();
|
|
106
|
-
|
|
107
|
-
if (
|
|
136
|
+
let data = {};
|
|
137
|
+
if (text) { try { data = JSON.parse(text); } catch { /* non-JSON error page */ } }
|
|
138
|
+
if (!res.ok) throw new Error(data.error || friendlyStatus(res.status));
|
|
108
139
|
return data;
|
|
109
140
|
}
|
|
141
|
+
// Friendly fallback when the server didn't supply a message — never show a raw status code.
|
|
142
|
+
function friendlyStatus(status) {
|
|
143
|
+
if (status === 404) return '요청한 항목을 찾을 수 없어요. 새로고침 후 다시 시도해 주세요.';
|
|
144
|
+
if (status === 403) return '권한 확인이 필요해요. 대시보드를 새로고침해 주세요.';
|
|
145
|
+
if (status >= 500) return '일시적인 오류가 발생했어요. 잠시 후 다시 시도해 주세요.';
|
|
146
|
+
return '요청을 처리하지 못했어요. 입력을 확인하고 다시 시도해 주세요.';
|
|
147
|
+
}
|
|
110
148
|
export const get = (p) => api('GET', p);
|
|
111
149
|
export const post = (p, b) => api('POST', p, b);
|
|
112
150
|
export const put = (p, b) => api('PUT', p, b);
|
|
@@ -134,6 +172,22 @@ export function scoreClass(score) {
|
|
|
134
172
|
if (score >= 50) return 'score-mid';
|
|
135
173
|
return 'score-weak';
|
|
136
174
|
}
|
|
175
|
+
/** Textarea (newline-separated) → trimmed array, empties dropped. */
|
|
176
|
+
export function linesToArray(str) { return String(str || '').split('\n').map((s) => s.trim()).filter(Boolean); }
|
|
177
|
+
/** CSV ("a, b, c") → trimmed array, empties dropped. */
|
|
178
|
+
export function csvToArray(str) { return String(str || '').split(',').map((s) => s.trim()).filter(Boolean); }
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Application status code → saturated theme colour. Single source of truth so a
|
|
182
|
+
* colour means the same pipeline stage everywhere (home pipeline bar/dots,
|
|
183
|
+
* applications board dots, badges). MUST track the badge--{status} hues in CSS.
|
|
184
|
+
*/
|
|
185
|
+
export const STATUS_COLOR = {
|
|
186
|
+
draft: 'var(--slate)', planned: 'var(--blue)', applied: 'var(--violet)',
|
|
187
|
+
document_passed: 'var(--teal)', interview: 'var(--amber)',
|
|
188
|
+
final_passed: 'var(--green)', on_hold: 'var(--slate)', rejected: 'var(--red)',
|
|
189
|
+
};
|
|
190
|
+
export const statusColor = (code) => STATUS_COLOR[code] || 'var(--text-tertiary)';
|
|
137
191
|
|
|
138
192
|
/* ---------------------------------------------------------------- routing */
|
|
139
193
|
export function navigate(hash) { location.hash = hash.startsWith('#') ? hash : '#' + hash; }
|
|
@@ -149,17 +203,50 @@ export function Btn(label, { icon: ic, variant = '', sm, onClick, type, disabled
|
|
|
149
203
|
export function IconBtn(name, { onClick, title, variant = 'ghost' } = {}) {
|
|
150
204
|
return el('button', { class: `btn btn--${variant} icon-btn`, onClick, title, attrs: { 'aria-label': title || name } }, icon(name));
|
|
151
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* A footer button for async saves. While `action()` runs the button is disabled
|
|
208
|
+
* (so a slow local round-trip can't be double-clicked into duplicate records),
|
|
209
|
+
* and if it throws the message is shown in the modal's persistent `.modal__error`
|
|
210
|
+
* slot (not a transient toast) so the user sees why the save failed and can fix
|
|
211
|
+
* and retry. `action` should THROW on failure (validation or API) and is expected
|
|
212
|
+
* to call close()/reload() itself on success.
|
|
213
|
+
*/
|
|
214
|
+
export function SubmitBtn(label, action, { variant = 'primary', icon: ic } = {}) {
|
|
215
|
+
let busy = false;
|
|
216
|
+
const btn = Btn(label, {
|
|
217
|
+
variant,
|
|
218
|
+
icon: ic,
|
|
219
|
+
onClick: async () => {
|
|
220
|
+
if (busy) return;
|
|
221
|
+
busy = true;
|
|
222
|
+
btn.disabled = true;
|
|
223
|
+
const slot = btn.closest('.modal')?.querySelector('.modal__error');
|
|
224
|
+
if (slot) { slot.hidden = true; slot.textContent = ''; }
|
|
225
|
+
try {
|
|
226
|
+
await action();
|
|
227
|
+
} catch (e) {
|
|
228
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
229
|
+
if (slot) { slot.textContent = msg; slot.hidden = false; slot.scrollIntoView?.({ block: 'nearest' }); }
|
|
230
|
+
else toastError(e);
|
|
231
|
+
} finally {
|
|
232
|
+
busy = false;
|
|
233
|
+
btn.disabled = false;
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
return btn;
|
|
238
|
+
}
|
|
152
239
|
export function Card({ title, sub, actions, body, clickable, onClick } = {}) {
|
|
153
240
|
const head = (title || actions) && el('div', { class: 'card__head' },
|
|
154
241
|
title && el('h3', {}, title),
|
|
155
242
|
sub && el('span', { class: 'sub' }, sub),
|
|
156
243
|
actions && el('div', { class: 'right' }, ...[].concat(actions)),
|
|
157
244
|
);
|
|
158
|
-
return el('div', { class: `card${clickable ? ' is-clickable' : ''}`, onClick },
|
|
245
|
+
return el('div', { class: `card${clickable ? ' is-clickable' : ''}`, activate: onClick },
|
|
159
246
|
head, el('div', { class: 'card__body' }, ...[].concat(body || [])));
|
|
160
247
|
}
|
|
161
|
-
export function Stat({ label, value, hint, iconName }) {
|
|
162
|
-
return el('div', { class: 'stat' },
|
|
248
|
+
export function Stat({ label, value, hint, iconName, onClick }) {
|
|
249
|
+
return el('div', { class: 'stat', activate: onClick },
|
|
163
250
|
el('div', { class: 'stat__label' }, iconName && icon(iconName), label),
|
|
164
251
|
el('div', { class: 'stat__value tnum' }, value),
|
|
165
252
|
hint && el('div', { class: 'stat__hint' }, hint),
|
|
@@ -178,7 +265,7 @@ export function Chips(items, { accent } = {}) {
|
|
|
178
265
|
}
|
|
179
266
|
/** Dense list row: leading slot → title + muted sub → right-aligned trailing. */
|
|
180
267
|
export function ListRow({ leading, title, sub, trailing, onClick } = {}) {
|
|
181
|
-
return el('div', { class: `list-row${onClick ? ' is-clickable' : ''}`, onClick },
|
|
268
|
+
return el('div', { class: `list-row${onClick ? ' is-clickable' : ''}`, activate: onClick },
|
|
182
269
|
leading && el('div', { class: 'list-row__lead' }, leading),
|
|
183
270
|
el('div', { class: 'list-row__main' },
|
|
184
271
|
el('div', { class: 'list-row__title' }, title),
|
|
@@ -187,9 +274,11 @@ export function ListRow({ leading, title, sub, trailing, onClick } = {}) {
|
|
|
187
274
|
}
|
|
188
275
|
/** Onboarding checklist row: hollow dot (todo, links via onClick) or green check (done, struck through). */
|
|
189
276
|
export function CheckRow({ done, label, onClick } = {}) {
|
|
190
|
-
|
|
277
|
+
// Whole row is the activation target (bigger hit area + keyboard) when it's a todo.
|
|
278
|
+
const interactive = !done && typeof onClick === 'function';
|
|
279
|
+
return el('div', { class: `check-row${done ? ' is-done' : ''}`, activate: interactive ? onClick : undefined },
|
|
191
280
|
done ? icon('check', 'check-row__icon') : el('span', { class: 'check-row__dot' }),
|
|
192
|
-
el('span', { class: `check-row__label${
|
|
281
|
+
el('span', { class: `check-row__label${interactive ? ' is-link' : ''}` }, label));
|
|
193
282
|
}
|
|
194
283
|
export function Field(label, control, hint) {
|
|
195
284
|
return el('div', { class: 'field' }, label && el('label', {}, label), control, hint && el('div', { class: 'hint' }, hint));
|
|
@@ -222,6 +311,8 @@ export function toastOk(msg) { toast(msg, { type: 'success' }); }
|
|
|
222
311
|
|
|
223
312
|
/* ------------------------------------------------------------------ modal */
|
|
224
313
|
let modalRoot;
|
|
314
|
+
let modalReturnFocus = null;
|
|
315
|
+
const MODAL_FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),textarea:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
225
316
|
function ensureModalRoot() {
|
|
226
317
|
if (!modalRoot) {
|
|
227
318
|
modalRoot = el('div', { class: 'modal-root' });
|
|
@@ -230,7 +321,11 @@ function ensureModalRoot() {
|
|
|
230
321
|
}
|
|
231
322
|
return modalRoot;
|
|
232
323
|
}
|
|
233
|
-
export function closeModal() {
|
|
324
|
+
export function closeModal() {
|
|
325
|
+
if (modalRoot) { modalRoot.classList.remove('is-open'); clear(modalRoot); }
|
|
326
|
+
// Return focus to whatever opened the modal so keyboard users aren't dumped at the page top.
|
|
327
|
+
if (modalReturnFocus) { try { modalReturnFocus.focus(); } catch { /* element gone */ } modalReturnFocus = null; }
|
|
328
|
+
}
|
|
234
329
|
/**
|
|
235
330
|
* Open a modal. `render(close)` returns the modal body content. Provide title,
|
|
236
331
|
* optional footer buttons via `footer(close)`. Returns nothing; call closeModal().
|
|
@@ -238,18 +333,36 @@ export function closeModal() { if (modalRoot) { modalRoot.classList.remove('is-o
|
|
|
238
333
|
export function openModal({ title, body, footer, size }) {
|
|
239
334
|
const root = ensureModalRoot();
|
|
240
335
|
const close = closeModal;
|
|
336
|
+
modalReturnFocus = document.activeElement; // restored by closeModal
|
|
241
337
|
const closeBtn = IconBtn('plus', { title: '닫기', onClick: close }); // rotated to look like ×
|
|
242
338
|
closeBtn.style.transform = 'rotate(45deg)';
|
|
243
|
-
const modal = el('div', {
|
|
244
|
-
|
|
339
|
+
const modal = el('div', {
|
|
340
|
+
class: `modal${size === 'lg' ? ' modal--lg' : ''}`,
|
|
341
|
+
attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'modal-title' },
|
|
342
|
+
},
|
|
343
|
+
el('div', { class: 'modal__head' }, el('h3', { id: 'modal-title' }, title), el('div', { class: 'topbar__spacer' }), closeBtn),
|
|
245
344
|
);
|
|
345
|
+
// Focus trap: keep Tab / Shift+Tab inside the dialog instead of leaking to the page behind.
|
|
346
|
+
modal.addEventListener('keydown', (e) => {
|
|
347
|
+
if (e.key !== 'Tab') return;
|
|
348
|
+
const f = [...modal.querySelectorAll(MODAL_FOCUSABLE)].filter((x) => x.offsetParent !== null);
|
|
349
|
+
if (!f.length) return;
|
|
350
|
+
const first = f[0], last = f[f.length - 1];
|
|
351
|
+
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
352
|
+
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
353
|
+
});
|
|
246
354
|
const bodyNode = el('div', { class: 'modal__body' });
|
|
247
355
|
appendChildren(bodyNode, [typeof body === 'function' ? body(close) : body]);
|
|
248
356
|
modal.append(bodyNode);
|
|
357
|
+
// Persistent inline error slot so a failed save shows a clear reason instead of
|
|
358
|
+
// relying on a 3.8s toast. Placed between body and footer (always visible). Any
|
|
359
|
+
// SubmitBtn inside the modal writes here — including inline editors in bodies
|
|
360
|
+
// that have no footer; see SubmitBtn.
|
|
361
|
+
modal.append(el('div', { class: 'modal__error', hidden: true, attrs: { role: 'alert', 'aria-live': 'assertive' } }));
|
|
249
362
|
if (footer) modal.append(el('div', { class: 'modal__foot' }, ...[].concat(footer(close))));
|
|
250
363
|
mount(root, el('div', { class: 'modal__scrim', onClick: close }), modal);
|
|
251
364
|
root.classList.add('is-open');
|
|
252
|
-
setTimeout(() => bodyNode.querySelector('input,textarea,select
|
|
365
|
+
setTimeout(() => (bodyNode.querySelector('input,textarea,select') || modal.querySelector(MODAL_FOCUSABLE))?.focus(), 30);
|
|
253
366
|
}
|
|
254
367
|
/** Confirmation dialog. Returns a Promise<boolean>. */
|
|
255
368
|
export function confirmDialog({ title = '확인', message, confirmLabel = '확인', danger } = {}) {
|
|
@@ -275,7 +388,6 @@ export function downloadUrl(url) {
|
|
|
275
388
|
const a = el('a', { href: url, download: '' });
|
|
276
389
|
document.body.append(a); a.click(); a.remove();
|
|
277
390
|
}
|
|
278
|
-
export function debounce(fn, ms = 250) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
|
|
279
391
|
|
|
280
392
|
/** Shared meta (statuses, document kinds) loaded once. */
|
|
281
393
|
let _meta = null;
|
|
@@ -2,15 +2,21 @@
|
|
|
2
2
|
// (no horizontal scroll). Change a status inline to move an item between groups.
|
|
3
3
|
import {
|
|
4
4
|
el, get, put, navigate, EmptyState, Btn, ListRow,
|
|
5
|
-
Select, scoreClass, mount, toast, toastOk, toastError, meta,
|
|
5
|
+
Select, scoreClass, statusColor, mount, toast, toastOk, toastError, meta,
|
|
6
6
|
} from '/lib.js';
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
// deadline(YYYY-MM-DD) → "마감 D-7" / "마감 D-day" / "마감 지남" (null if absent/invalid)
|
|
9
|
+
function ddayLabel(deadline) {
|
|
10
|
+
if (!deadline) return null;
|
|
11
|
+
const d = new Date(`${deadline}T00:00:00`);
|
|
12
|
+
if (isNaN(d.getTime())) return null;
|
|
13
|
+
const today = new Date();
|
|
14
|
+
today.setHours(0, 0, 0, 0);
|
|
15
|
+
const days = Math.round((d.getTime() - today.getTime()) / 86400000);
|
|
16
|
+
if (days > 0) return `마감 D-${days}`;
|
|
17
|
+
if (days === 0) return '마감 D-day';
|
|
18
|
+
return '마감 지남';
|
|
19
|
+
}
|
|
14
20
|
|
|
15
21
|
export async function render(ctx) {
|
|
16
22
|
ctx.setActions([]);
|
|
@@ -50,7 +56,7 @@ export async function render(ctx) {
|
|
|
50
56
|
function StatusGroup(code, apps) {
|
|
51
57
|
return el('div', {},
|
|
52
58
|
el('div', { class: 'app-group__head' },
|
|
53
|
-
el('span', { class: 'app-group__dot', style: { background:
|
|
59
|
+
el('span', { class: 'app-group__dot', style: { background: statusColor(code) } }),
|
|
54
60
|
el('span', { class: 'app-group__title' }, labelByCode[code] || code),
|
|
55
61
|
el('span', { class: 'app-group__count tnum' }, `${apps.length}건`)),
|
|
56
62
|
el('div', {}, ...apps.map(AppRow)));
|
|
@@ -58,6 +64,9 @@ export async function render(ctx) {
|
|
|
58
64
|
|
|
59
65
|
function AppRow(app) {
|
|
60
66
|
const job = app.job || {};
|
|
67
|
+
const subBits = [job.position, job.location].filter(Boolean);
|
|
68
|
+
const dday = ddayLabel(job.deadline);
|
|
69
|
+
if (dday) subBits.push(dday);
|
|
61
70
|
const sel = Select(
|
|
62
71
|
statusOptions.map((o) => ({ ...o, selected: o.value === app.status })),
|
|
63
72
|
{
|
|
@@ -69,7 +78,7 @@ export async function render(ctx) {
|
|
|
69
78
|
);
|
|
70
79
|
return ListRow({
|
|
71
80
|
title: job.company || '—',
|
|
72
|
-
sub:
|
|
81
|
+
sub: subBits.join(' · '),
|
|
73
82
|
trailing: [
|
|
74
83
|
app.fit_score != null
|
|
75
84
|
? el('span', { class: `strong tnum ${scoreClass(app.fit_score)}` }, `${app.fit_score}점`)
|