careermate 0.0.1
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 +33 -0
- package/INSTALL.md +230 -0
- package/LICENSE +21 -0
- package/README.md +532 -0
- package/THIRD_PARTY_NOTICES.md +57 -0
- package/bin/careermate.mjs +77 -0
- package/dist/career-os/README.md +78 -0
- package/dist/career-os/eop/cover-letter.md +46 -0
- package/dist/career-os/eop/fit-analysis.md +48 -0
- package/dist/career-os/eop/interview-prep.md +39 -0
- package/dist/career-os/eop/job-analysis.md +42 -0
- package/dist/career-os/eop/profile-extraction.md +37 -0
- package/dist/career-os/knowledge/ats.md +131 -0
- package/dist/career-os/knowledge/company-research.md +137 -0
- package/dist/career-os/knowledge/cover-letter.md +135 -0
- package/dist/career-os/knowledge/fit-matching.md +121 -0
- package/dist/career-os/knowledge/human-writing.md +118 -0
- package/dist/career-os/knowledge/interview-behavioral.md +132 -0
- package/dist/career-os/knowledge/interview-technical.md +126 -0
- package/dist/career-os/knowledge/linkedin-profile.md +152 -0
- package/dist/career-os/knowledge/networking-referrals.md +166 -0
- package/dist/career-os/knowledge/offer-evaluation-decision.md +121 -0
- package/dist/career-os/knowledge/onboarding-first-90-days.md +126 -0
- package/dist/career-os/knowledge/portfolio.md +121 -0
- package/dist/career-os/knowledge/recruiter-screen.md +105 -0
- package/dist/career-os/knowledge/rejection-triage-iteration.md +115 -0
- package/dist/career-os/knowledge/resume.md +123 -0
- package/dist/career-os/knowledge/salary-negotiation.md +123 -0
- package/dist/career-os/knowledge/verifiers/ats-compat.md +64 -0
- package/dist/career-os/knowledge/verifiers/consistency.md +61 -0
- package/dist/career-os/knowledge/verifiers/human-voice.md +82 -0
- package/dist/career-os/knowledge/verifiers/recency-staleness.md +72 -0
- package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +82 -0
- package/dist/career-os/knowledge/verifiers/truthfulness.md +67 -0
- package/dist/doctor.mjs +79094 -0
- package/dist/mcp.mjs +96108 -0
- package/dist/migrate.mjs +4735 -0
- package/dist/pdf.worker.min.mjs +28 -0
- package/dist/public/app.js +150 -0
- package/dist/public/fonts/PretendardVariable.woff2 +0 -0
- package/dist/public/icon-180.png +0 -0
- package/dist/public/index.html +52 -0
- package/dist/public/lib.js +394 -0
- package/dist/public/pages/applications.js +107 -0
- package/dist/public/pages/documents.js +467 -0
- package/dist/public/pages/home.js +261 -0
- package/dist/public/pages/interview.js +227 -0
- package/dist/public/pages/jobs.js +493 -0
- package/dist/public/pages/profile.js +556 -0
- package/dist/public/pages/settings.js +240 -0
- package/dist/public/styles.css +498 -0
- package/dist/site/analytics.js +97 -0
- package/dist/site/favicon.svg +1 -0
- package/dist/site/fonts/PretendardVariable.woff2 +0 -0
- package/dist/site/icon-180.png +0 -0
- package/dist/site/icon-512.png +0 -0
- package/dist/site/index.html +598 -0
- package/dist/site/landing.js +54 -0
- package/dist/site/llms-install.txt +217 -0
- package/dist/site/robots.txt +4 -0
- package/dist/site/sitemap.xml +21 -0
- package/dist/site/start.html +326 -0
- package/dist/site/style.css +550 -0
- package/dist/site/vercel.json +20 -0
- package/dist/web.mjs +46852 -0
- package/docs/FAQ.md +101 -0
- package/docs/MCP_TOOLS.md +311 -0
- package/docs/README.md +28 -0
- package/docs/START_WORKFLOW.md +137 -0
- package/docs/SUPPORTED_AI_APPS.md +112 -0
- package/docs/WORKFLOWS.md +202 -0
- package/docs/career-os/README.md +78 -0
- package/docs/career-os/eop/cover-letter.md +46 -0
- package/docs/career-os/eop/fit-analysis.md +48 -0
- package/docs/career-os/eop/interview-prep.md +39 -0
- package/docs/career-os/eop/job-analysis.md +42 -0
- package/docs/career-os/eop/profile-extraction.md +37 -0
- package/docs/career-os/knowledge/ats.md +131 -0
- package/docs/career-os/knowledge/company-research.md +137 -0
- package/docs/career-os/knowledge/cover-letter.md +135 -0
- package/docs/career-os/knowledge/fit-matching.md +121 -0
- package/docs/career-os/knowledge/human-writing.md +118 -0
- package/docs/career-os/knowledge/interview-behavioral.md +132 -0
- package/docs/career-os/knowledge/interview-technical.md +126 -0
- package/docs/career-os/knowledge/linkedin-profile.md +152 -0
- package/docs/career-os/knowledge/networking-referrals.md +166 -0
- package/docs/career-os/knowledge/offer-evaluation-decision.md +121 -0
- package/docs/career-os/knowledge/onboarding-first-90-days.md +126 -0
- package/docs/career-os/knowledge/portfolio.md +121 -0
- package/docs/career-os/knowledge/recruiter-screen.md +105 -0
- package/docs/career-os/knowledge/rejection-triage-iteration.md +115 -0
- package/docs/career-os/knowledge/resume.md +123 -0
- package/docs/career-os/knowledge/salary-negotiation.md +123 -0
- package/docs/career-os/knowledge/verifiers/ats-compat.md +64 -0
- package/docs/career-os/knowledge/verifiers/consistency.md +61 -0
- package/docs/career-os/knowledge/verifiers/human-voice.md +82 -0
- package/docs/career-os/knowledge/verifiers/recency-staleness.md +72 -0
- package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +82 -0
- package/docs/career-os/knowledge/verifiers/truthfulness.md +67 -0
- package/package.json +89 -0
- package/packages/core/src/context.ts +74 -0
- package/packages/core/src/index.ts +8 -0
- package/packages/core/src/onboarding.ts +81 -0
- package/packages/core/src/services.ts +187 -0
- package/packages/core/src/summary.ts +104 -0
- package/packages/db/src/connection.ts +76 -0
- package/packages/db/src/index.ts +23 -0
- package/packages/db/src/paths.ts +52 -0
- package/packages/db/src/repositories.ts +891 -0
- package/packages/db/src/runtime.ts +58 -0
- package/packages/db/src/schema.ts +307 -0
- package/packages/exporters/src/html.ts +110 -0
- package/packages/exporters/src/index.ts +364 -0
- package/packages/exporters/src/markdown.ts +178 -0
- package/packages/knowledge/src/index.ts +168 -0
- package/packages/mcp-tools/src/bridge.ts +106 -0
- package/packages/mcp-tools/src/index.ts +17 -0
- package/packages/mcp-tools/src/result.ts +49 -0
- package/packages/mcp-tools/src/tools.ts +747 -0
- package/packages/mcp-tools/src/update.ts +330 -0
- package/packages/parsers/src/documents.ts +533 -0
- package/packages/parsers/src/html.ts +86 -0
- package/packages/parsers/src/index.ts +231 -0
- package/packages/parsers/src/keywords.ts +151 -0
- package/packages/prompts/src/humanize.ts +59 -0
- package/packages/prompts/src/index.ts +82 -0
- package/packages/prompts/src/install.ts +43 -0
- package/packages/prompts/src/onboarding.ts +35 -0
- package/packages/prompts/src/system.ts +57 -0
- package/packages/shared/src/build-info.ts +22 -0
- package/packages/shared/src/enums.ts +142 -0
- package/packages/shared/src/index.ts +19 -0
- package/packages/shared/src/schemas.ts +453 -0
- package/packages/workflows/src/definitions.ts +109 -0
- package/packages/workflows/src/index.ts +39 -0
- package/scripts/init.ts +371 -0
- package/tsconfig.json +32 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// CareerMate dashboard shell — hash router, sidebar, and page orchestration.
|
|
3
|
+
// Page modules live in /pages/*.js and export `render(ctx)`. They receive a ctx
|
|
4
|
+
// with { view, params, setTitle, setActions, refreshNav } and fill the view.
|
|
5
|
+
// =============================================================================
|
|
6
|
+
import { el, icon, get, navigate, toastError, Spinner, clear } from '/lib.js';
|
|
7
|
+
|
|
8
|
+
const ROUTES = [
|
|
9
|
+
{ id: 'home', label: '홈', icon: 'home', module: '/pages/home.js' },
|
|
10
|
+
{ id: 'profile', label: '프로필', icon: 'user', module: '/pages/profile.js' },
|
|
11
|
+
{ id: 'jobs', label: '채용공고', icon: 'briefcase', module: '/pages/jobs.js', countKey: 'jobs' },
|
|
12
|
+
{ id: 'applications', label: '지원 현황', icon: 'layers', module: '/pages/applications.js', countKey: 'active_applications' },
|
|
13
|
+
{ id: 'documents', label: '문서', icon: 'file', module: '/pages/documents.js', countKey: 'cover_letters' },
|
|
14
|
+
{ id: 'interview', label: '면접 준비', icon: 'mic', module: '/pages/interview.js', countKey: 'interview_pending' },
|
|
15
|
+
{ id: 'settings', label: '설정', icon: 'settings', module: '/pages/settings.js' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const view = document.getElementById('view');
|
|
19
|
+
const navEl = document.getElementById('nav');
|
|
20
|
+
const titleEl = document.getElementById('topbar-title');
|
|
21
|
+
const actionsEl = document.getElementById('topbar-actions');
|
|
22
|
+
const routeStatusEl = document.getElementById('route-status');
|
|
23
|
+
const moduleCache = new Map();
|
|
24
|
+
|
|
25
|
+
function parseHash() {
|
|
26
|
+
const raw = location.hash.replace(/^#\/?/, '');
|
|
27
|
+
const segments = raw.split('/').filter(Boolean);
|
|
28
|
+
return { page: segments[0] || 'home', params: segments.slice(1) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderNav(counts = {}) {
|
|
32
|
+
clear(navEl);
|
|
33
|
+
navEl.append(el('div', { class: 'nav__label' }, '워크스페이스'));
|
|
34
|
+
const { page } = parseHash();
|
|
35
|
+
for (const r of ROUTES) {
|
|
36
|
+
if (r.id === 'settings') navEl.append(el('div', { class: 'nav__label' }, '시스템'));
|
|
37
|
+
const count = r.countKey ? counts[r.countKey] : null;
|
|
38
|
+
navEl.append(el('a', {
|
|
39
|
+
class: `nav__item${page === r.id ? ' is-active' : ''}`,
|
|
40
|
+
href: `#/${r.id}`,
|
|
41
|
+
}, icon(r.icon), el('span', {}, r.label),
|
|
42
|
+
count ? el('span', { class: 'nav__badge tnum' }, String(count)) : null));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function refreshNav() {
|
|
47
|
+
try {
|
|
48
|
+
const summary = await get('/api/summary');
|
|
49
|
+
renderNav(summary.counts);
|
|
50
|
+
} catch {
|
|
51
|
+
renderNav();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function loadModule(path) {
|
|
56
|
+
if (moduleCache.has(path)) return moduleCache.get(path);
|
|
57
|
+
const mod = await import(path);
|
|
58
|
+
moduleCache.set(path, mod);
|
|
59
|
+
return mod;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let renderToken = 0;
|
|
63
|
+
async function route() {
|
|
64
|
+
const token = ++renderToken;
|
|
65
|
+
const { page, params } = parseHash();
|
|
66
|
+
const def = ROUTES.find((r) => r.id === page) || ROUTES[0];
|
|
67
|
+
|
|
68
|
+
// active nav state
|
|
69
|
+
navEl.querySelectorAll('.nav__item').forEach((n) => n.classList.toggle('is-active', n.getAttribute('href') === `#/${def.id}`));
|
|
70
|
+
titleEl.textContent = def.label;
|
|
71
|
+
clear(actionsEl);
|
|
72
|
+
view.scrollTop = 0;
|
|
73
|
+
window.scrollTo(0, 0);
|
|
74
|
+
|
|
75
|
+
const ctx = {
|
|
76
|
+
view,
|
|
77
|
+
params,
|
|
78
|
+
setTitle: (t) => { titleEl.textContent = t; },
|
|
79
|
+
setActions: (nodes) => { clear(actionsEl); [].concat(nodes).filter(Boolean).forEach((n) => actionsEl.append(n)); },
|
|
80
|
+
navigate,
|
|
81
|
+
refreshNav,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
clear(view);
|
|
85
|
+
view.append(Spinner());
|
|
86
|
+
try {
|
|
87
|
+
const mod = await loadModule(def.module);
|
|
88
|
+
if (token !== renderToken) return; // a newer navigation superseded this one
|
|
89
|
+
clear(view);
|
|
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;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (token !== renderToken) return;
|
|
96
|
+
clear(view);
|
|
97
|
+
view.append(el('div', { class: 'card' }, el('div', { class: 'card__body' },
|
|
98
|
+
el('h3', { style: { marginBottom: '8px' } }, '페이지를 불러오지 못했습니다'),
|
|
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() }, '다시 시도'))));
|
|
101
|
+
toastError(err);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
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
|
+
|
|
124
|
+
function initTheme() {
|
|
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');
|
|
127
|
+
if (saved === 'light' || saved === 'dark') document.documentElement.dataset.theme = saved;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function boot() {
|
|
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
|
+
});
|
|
139
|
+
// Show data location in the footer (privacy: user can always see where data lives).
|
|
140
|
+
try {
|
|
141
|
+
const h = await get('/api/health');
|
|
142
|
+
document.getElementById('foot-path').textContent = h.data_dir;
|
|
143
|
+
document.getElementById('foot-path').title = h.data_dir;
|
|
144
|
+
} catch { /* ignore */ }
|
|
145
|
+
await refreshNav();
|
|
146
|
+
window.addEventListener('hashchange', route);
|
|
147
|
+
await route();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
boot();
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="ko">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>CareerMate — 내 커리어 흐름 관리</title>
|
|
7
|
+
<meta name="description" content="ChatGPT/Claude와 함께 쓰는 로컬 커리어 관리 도구" />
|
|
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" />
|
|
12
|
+
<link rel="preload" href="/fonts/PretendardVariable.woff2" as="font" type="font/woff2" crossorigin />
|
|
13
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<button class="skip-link" id="skip-link" type="button">본문으로 건너뛰기</button>
|
|
17
|
+
<div class="app">
|
|
18
|
+
<div class="nav-scrim" id="nav-scrim"></div>
|
|
19
|
+
<aside class="sidebar" id="sidebar">
|
|
20
|
+
<div class="sidebar__brand">
|
|
21
|
+
<div class="sidebar__logo">C</div>
|
|
22
|
+
<div>
|
|
23
|
+
<div class="sidebar__title">CareerMate</div>
|
|
24
|
+
<div class="sidebar__subtitle">내 커리어 흐름 관리</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
<nav class="nav" id="nav"></nav>
|
|
28
|
+
<div class="sidebar__foot">
|
|
29
|
+
<div><span class="dot"></span><span id="foot-status">로컬에서 실행 중</span></div>
|
|
30
|
+
<div id="foot-path" class="truncate" style="margin-top:4px"></div>
|
|
31
|
+
</div>
|
|
32
|
+
</aside>
|
|
33
|
+
|
|
34
|
+
<div class="main">
|
|
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>
|
|
39
|
+
<div class="topbar__title" id="topbar-title">CareerMate</div>
|
|
40
|
+
<div class="topbar__spacer"></div>
|
|
41
|
+
<div id="topbar-actions" class="flex gap-2"></div>
|
|
42
|
+
</header>
|
|
43
|
+
<main id="view" class="view"></main>
|
|
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>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<script type="module" src="/app.js"></script>
|
|
51
|
+
</body>
|
|
52
|
+
</html>
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// CareerMate front-end library — DOM helpers, API client, and UI components.
|
|
3
|
+
// Every page module imports from here so the dashboard stays visually and
|
|
4
|
+
// behaviourally consistent. No framework, no build, no CDN.
|
|
5
|
+
//
|
|
6
|
+
// XSS safety: el() puts strings into textContent (never innerHTML). User content
|
|
7
|
+
// (résumé/cover-letter text) is always rendered as text. Only our own trusted
|
|
8
|
+
// icon SVG strings use innerHTML.
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
/* --------------------------------------------------------------- DOM helper */
|
|
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
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create an element. `props` may include: class, id, type, value, placeholder,
|
|
28
|
+
* href, title, disabled, dataset:{}, style:{}, attrs:{}, and on* event handlers
|
|
29
|
+
* (onClick, onInput, onChange, onSubmit, onKeydown). `children` are appended:
|
|
30
|
+
* strings/numbers become safe text nodes; nodes are appended as-is; null/false
|
|
31
|
+
* are ignored; arrays are flattened.
|
|
32
|
+
*/
|
|
33
|
+
export function el(tag, props = {}, ...children) {
|
|
34
|
+
const node = document.createElement(tag);
|
|
35
|
+
for (const [k, v] of Object.entries(props || {})) {
|
|
36
|
+
if (v == null || v === false) continue;
|
|
37
|
+
if (k === 'class') node.className = v;
|
|
38
|
+
else if (k === 'html') node.innerHTML = v; // trusted callers only
|
|
39
|
+
else if (k === 'text') node.textContent = v;
|
|
40
|
+
else if (k === 'dataset') Object.assign(node.dataset, v);
|
|
41
|
+
else if (k === 'style') Object.assign(node.style, v);
|
|
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
|
|
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
|
+
}
|
|
56
|
+
else if (k in node) { try { node[k] = v; } catch { node.setAttribute(k, v); } }
|
|
57
|
+
else node.setAttribute(k, v);
|
|
58
|
+
}
|
|
59
|
+
appendChildren(node, children);
|
|
60
|
+
return node;
|
|
61
|
+
}
|
|
62
|
+
function appendChildren(node, children) {
|
|
63
|
+
for (const c of children.flat(Infinity)) {
|
|
64
|
+
if (c == null || c === false || c === true) continue;
|
|
65
|
+
node.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); return node; }
|
|
69
|
+
export function mount(node, ...children) { clear(node); appendChildren(node, children); return node; }
|
|
70
|
+
|
|
71
|
+
/* ------------------------------------------------------------------- icons */
|
|
72
|
+
// Minimal, consistent 1.6px stroke icon set (Lucide-style). Trusted strings.
|
|
73
|
+
const ICONS = {
|
|
74
|
+
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>',
|
|
75
|
+
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/>',
|
|
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"/>',
|
|
77
|
+
layers: '<path d="m12 3 9 5-9 5-9-5 9-5Z"/><path d="m3 13 9 5 9-5"/>',
|
|
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"/>',
|
|
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"/>',
|
|
80
|
+
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1A1.6 1.6 0 0 0 7 19.4a1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0-1.1-2.7H1a2 2 0 1 1 0-4h.1A1.6 1.6 0 0 0 4.6 7a1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.6 1.6 0 0 0 9 2.6h.1A2 2 0 1 1 13 2.6V3a1.6 1.6 0 0 0 2.7 1.1l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0 1.1 2.7H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1.4Z"/>',
|
|
81
|
+
plus: '<path d="M12 5v14M5 12h14"/>',
|
|
82
|
+
edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
|
83
|
+
trash: '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M6 6v14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V6"/>',
|
|
84
|
+
copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>',
|
|
85
|
+
download: '<path d="M12 3v12"/><path d="m7 11 5 4 5-4"/><path d="M5 21h14"/>',
|
|
86
|
+
external: '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/>',
|
|
87
|
+
check: '<path d="m20 6-11 11-5-5"/>',
|
|
88
|
+
sparkle: '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18"/>',
|
|
89
|
+
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
|
90
|
+
link: '<path d="M9 15 15 9"/><path d="M11 6.5 13 4.5a3.5 3.5 0 0 1 5 5l-2 2"/><path d="M13 17.5 11 19.5a3.5 3.5 0 0 1-5-5l2-2"/>',
|
|
91
|
+
lock: '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
|
|
92
|
+
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/>',
|
|
93
|
+
inbox: '<path d="M3 12h5l2 3h4l2-3h5"/><path d="M5 6h14l2 6v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7Z"/>',
|
|
94
|
+
target: '<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r="0.5"/>',
|
|
95
|
+
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>',
|
|
96
|
+
chevronRight: '<path d="m9 6 6 6-6 6"/>',
|
|
97
|
+
};
|
|
98
|
+
/** Returns an <svg> element for the given icon name. */
|
|
99
|
+
export function icon(name, cls = '') {
|
|
100
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
101
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
102
|
+
// Default intrinsic size so bare icons never balloon to fill their flex parent.
|
|
103
|
+
// CSS rules (.nav__item svg, .btn svg, .empty__icon svg, …) override this freely.
|
|
104
|
+
svg.setAttribute('width', '18');
|
|
105
|
+
svg.setAttribute('height', '18');
|
|
106
|
+
svg.setAttribute('fill', 'none');
|
|
107
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
108
|
+
svg.setAttribute('stroke-width', '1.7');
|
|
109
|
+
svg.setAttribute('stroke-linecap', 'round');
|
|
110
|
+
svg.setAttribute('stroke-linejoin', 'round');
|
|
111
|
+
if (cls) svg.setAttribute('class', cls);
|
|
112
|
+
svg.innerHTML = ICONS[name] || ICONS.info;
|
|
113
|
+
return svg;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* -------------------------------------------------------------- API client */
|
|
117
|
+
const TOKEN = document.querySelector('meta[name="careermate-token"]')?.content || '';
|
|
118
|
+
|
|
119
|
+
/** Call the JSON API. Throws Error(message) on non-2xx. */
|
|
120
|
+
export async function api(method, path, body) {
|
|
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
|
+
}
|
|
135
|
+
const text = await res.text();
|
|
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));
|
|
139
|
+
return data;
|
|
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
|
+
}
|
|
148
|
+
export const get = (p) => api('GET', p);
|
|
149
|
+
export const post = (p, b) => api('POST', p, b);
|
|
150
|
+
export const put = (p, b) => api('PUT', p, b);
|
|
151
|
+
export const del = (p) => api('DELETE', p);
|
|
152
|
+
|
|
153
|
+
/* ------------------------------------------------------------- formatters */
|
|
154
|
+
export function fmtDate(iso) {
|
|
155
|
+
if (!iso) return '';
|
|
156
|
+
const d = new Date(iso);
|
|
157
|
+
if (isNaN(d)) return iso;
|
|
158
|
+
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
|
|
159
|
+
}
|
|
160
|
+
export function fmtRelative(iso) {
|
|
161
|
+
if (!iso) return '';
|
|
162
|
+
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
|
163
|
+
if (diff < 60) return '방금 전';
|
|
164
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`;
|
|
165
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`;
|
|
166
|
+
if (diff < 604800) return `${Math.floor(diff / 86400)}일 전`;
|
|
167
|
+
return fmtDate(iso);
|
|
168
|
+
}
|
|
169
|
+
export function scoreClass(score) {
|
|
170
|
+
if (score == null) return 'muted';
|
|
171
|
+
if (score >= 75) return 'score-strong';
|
|
172
|
+
if (score >= 50) return 'score-mid';
|
|
173
|
+
return 'score-weak';
|
|
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)';
|
|
191
|
+
|
|
192
|
+
/* ---------------------------------------------------------------- routing */
|
|
193
|
+
export function navigate(hash) { location.hash = hash.startsWith('#') ? hash : '#' + hash; }
|
|
194
|
+
|
|
195
|
+
/* ------------------------------------------------------------- components */
|
|
196
|
+
export function Badge(status, label) {
|
|
197
|
+
return el('span', { class: `badge badge--${status}` }, el('span', { class: 'dot' }), label);
|
|
198
|
+
}
|
|
199
|
+
export function Btn(label, { icon: ic, variant = '', sm, onClick, type, disabled, title } = {}) {
|
|
200
|
+
const cls = ['btn', variant && `btn--${variant}`, sm && 'btn--sm'].filter(Boolean).join(' ');
|
|
201
|
+
return el('button', { class: cls, onClick, type: type || 'button', disabled, title }, ic && icon(ic), label && el('span', {}, label));
|
|
202
|
+
}
|
|
203
|
+
export function IconBtn(name, { onClick, title, variant = 'ghost' } = {}) {
|
|
204
|
+
return el('button', { class: `btn btn--${variant} icon-btn`, onClick, title, attrs: { 'aria-label': title || name } }, icon(name));
|
|
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
|
+
}
|
|
239
|
+
export function Card({ title, sub, actions, body, clickable, onClick } = {}) {
|
|
240
|
+
const head = (title || actions) && el('div', { class: 'card__head' },
|
|
241
|
+
title && el('h3', {}, title),
|
|
242
|
+
sub && el('span', { class: 'sub' }, sub),
|
|
243
|
+
actions && el('div', { class: 'right' }, ...[].concat(actions)),
|
|
244
|
+
);
|
|
245
|
+
return el('div', { class: `card${clickable ? ' is-clickable' : ''}`, activate: onClick },
|
|
246
|
+
head, el('div', { class: 'card__body' }, ...[].concat(body || [])));
|
|
247
|
+
}
|
|
248
|
+
export function Stat({ label, value, hint, iconName, onClick }) {
|
|
249
|
+
return el('div', { class: 'stat', activate: onClick },
|
|
250
|
+
el('div', { class: 'stat__label' }, iconName && icon(iconName), label),
|
|
251
|
+
el('div', { class: 'stat__value tnum' }, value),
|
|
252
|
+
hint && el('div', { class: 'stat__hint' }, hint),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
export function EmptyState({ iconName = 'inbox', title, body, action }) {
|
|
256
|
+
return el('div', { class: 'empty' },
|
|
257
|
+
el('div', { class: 'empty__icon' }, icon(iconName)),
|
|
258
|
+
el('h3', {}, title),
|
|
259
|
+
body && el('p', {}, body),
|
|
260
|
+
action,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
export function Chips(items, { accent } = {}) {
|
|
264
|
+
return el('div', { class: 'chips' }, ...(items || []).map((t) => el('span', { class: `chip${accent ? ' chip--accent' : ''}` }, t)));
|
|
265
|
+
}
|
|
266
|
+
/** Dense list row: leading slot → title + muted sub → right-aligned trailing. */
|
|
267
|
+
export function ListRow({ leading, title, sub, trailing, onClick } = {}) {
|
|
268
|
+
return el('div', { class: `list-row${onClick ? ' is-clickable' : ''}`, activate: onClick },
|
|
269
|
+
leading && el('div', { class: 'list-row__lead' }, leading),
|
|
270
|
+
el('div', { class: 'list-row__main' },
|
|
271
|
+
el('div', { class: 'list-row__title' }, title),
|
|
272
|
+
sub && el('div', { class: 'list-row__sub' }, sub)),
|
|
273
|
+
trailing && el('div', { class: 'list-row__trail' }, ...[].concat(trailing)));
|
|
274
|
+
}
|
|
275
|
+
/** Onboarding checklist row: hollow dot (todo, links via onClick) or green check (done, struck through). */
|
|
276
|
+
export function CheckRow({ done, label, onClick } = {}) {
|
|
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 },
|
|
280
|
+
done ? icon('check', 'check-row__icon') : el('span', { class: 'check-row__dot' }),
|
|
281
|
+
el('span', { class: `check-row__label${interactive ? ' is-link' : ''}` }, label));
|
|
282
|
+
}
|
|
283
|
+
export function Field(label, control, hint) {
|
|
284
|
+
return el('div', { class: 'field' }, label && el('label', {}, label), control, hint && el('div', { class: 'hint' }, hint));
|
|
285
|
+
}
|
|
286
|
+
export function Input(props = {}) { return el('input', { class: 'input', ...props }); }
|
|
287
|
+
export function Textarea(props = {}) { return el('textarea', { class: 'textarea', ...props }); }
|
|
288
|
+
export function Select(options, props = {}) {
|
|
289
|
+
return el('select', { class: 'select', ...props }, ...options.map((o) => el('option', { value: o.value, selected: o.selected }, o.label)));
|
|
290
|
+
}
|
|
291
|
+
export function PageHead({ title, desc, actions }) {
|
|
292
|
+
return el('div', { class: 'page-head' },
|
|
293
|
+
el('div', { class: 'page-head__text' }, el('h1', {}, title), desc && el('p', {}, desc)),
|
|
294
|
+
actions && el('div', { class: 'page-head__actions' }, ...[].concat(actions)),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
export function Spinner() { return el('div', { class: 'view' }, el('div', { class: 'card' }, el('div', { class: 'card__body' }, el('div', { class: 'skeleton', style: { height: '120px' } })))); }
|
|
298
|
+
|
|
299
|
+
/* ------------------------------------------------------------------ toast */
|
|
300
|
+
let toastRoot;
|
|
301
|
+
export function toast(message, { title, type = 'default', timeout = 3800 } = {}) {
|
|
302
|
+
if (!toastRoot) { toastRoot = el('div', { class: 'toasts' }); document.body.append(toastRoot); }
|
|
303
|
+
const t = el('div', { class: `toast toast--${type}` },
|
|
304
|
+
title && el('div', { class: 'toast__title' }, title),
|
|
305
|
+
el('div', { class: 'toast__body' }, message));
|
|
306
|
+
toastRoot.append(t);
|
|
307
|
+
setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .25s'; setTimeout(() => t.remove(), 260); }, timeout);
|
|
308
|
+
}
|
|
309
|
+
export function toastError(e) { toast(e instanceof Error ? e.message : String(e), { title: '오류', type: 'error' }); }
|
|
310
|
+
export function toastOk(msg) { toast(msg, { type: 'success' }); }
|
|
311
|
+
|
|
312
|
+
/* ------------------------------------------------------------------ modal */
|
|
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"])';
|
|
316
|
+
function ensureModalRoot() {
|
|
317
|
+
if (!modalRoot) {
|
|
318
|
+
modalRoot = el('div', { class: 'modal-root' });
|
|
319
|
+
document.body.append(modalRoot);
|
|
320
|
+
modalRoot.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
|
|
321
|
+
}
|
|
322
|
+
return modalRoot;
|
|
323
|
+
}
|
|
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
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Open a modal. `render(close)` returns the modal body content. Provide title,
|
|
331
|
+
* optional footer buttons via `footer(close)`. Returns nothing; call closeModal().
|
|
332
|
+
*/
|
|
333
|
+
export function openModal({ title, body, footer, size }) {
|
|
334
|
+
const root = ensureModalRoot();
|
|
335
|
+
const close = closeModal;
|
|
336
|
+
modalReturnFocus = document.activeElement; // restored by closeModal
|
|
337
|
+
const closeBtn = IconBtn('plus', { title: '닫기', onClick: close }); // rotated to look like ×
|
|
338
|
+
closeBtn.style.transform = 'rotate(45deg)';
|
|
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),
|
|
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
|
+
});
|
|
354
|
+
const bodyNode = el('div', { class: 'modal__body' });
|
|
355
|
+
appendChildren(bodyNode, [typeof body === 'function' ? body(close) : body]);
|
|
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' } }));
|
|
362
|
+
if (footer) modal.append(el('div', { class: 'modal__foot' }, ...[].concat(footer(close))));
|
|
363
|
+
mount(root, el('div', { class: 'modal__scrim', onClick: close }), modal);
|
|
364
|
+
root.classList.add('is-open');
|
|
365
|
+
setTimeout(() => (bodyNode.querySelector('input,textarea,select') || modal.querySelector(MODAL_FOCUSABLE))?.focus(), 30);
|
|
366
|
+
}
|
|
367
|
+
/** Confirmation dialog. Returns a Promise<boolean>. */
|
|
368
|
+
export function confirmDialog({ title = '확인', message, confirmLabel = '확인', danger } = {}) {
|
|
369
|
+
return new Promise((resolve) => {
|
|
370
|
+
openModal({
|
|
371
|
+
title,
|
|
372
|
+
body: el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } }, message),
|
|
373
|
+
footer: (close) => [
|
|
374
|
+
Btn('취소', { onClick: () => { close(); resolve(false); } }),
|
|
375
|
+
Btn(confirmLabel, { variant: danger ? 'danger' : 'primary', onClick: () => { close(); resolve(true); } }),
|
|
376
|
+
],
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/* ---------------------------------------------------------------- utility */
|
|
382
|
+
export async function copyText(text) {
|
|
383
|
+
try { await navigator.clipboard.writeText(text); toastOk('클립보드에 복사했습니다.'); }
|
|
384
|
+
catch { toast('복사에 실패했습니다. 직접 선택해 복사해 주세요.', { type: 'error' }); }
|
|
385
|
+
}
|
|
386
|
+
/** Trigger a file download via a GET endpoint (no token needed; read-only). */
|
|
387
|
+
export function downloadUrl(url) {
|
|
388
|
+
const a = el('a', { href: url, download: '' });
|
|
389
|
+
document.body.append(a); a.click(); a.remove();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Shared meta (statuses, document kinds) loaded once. */
|
|
393
|
+
let _meta = null;
|
|
394
|
+
export async function meta() { if (!_meta) _meta = await get('/api/meta'); return _meta; }
|