careermate 0.1.0
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 +256 -0
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/apps/mcp/src/index.ts +66 -0
- package/apps/web/DESIGN_GUIDE.md +105 -0
- package/apps/web/UI_CONTRACT.md +44 -0
- package/apps/web/public/app.js +118 -0
- package/apps/web/public/fonts/PretendardVariable.woff2 +0 -0
- package/apps/web/public/index.html +41 -0
- package/apps/web/public/lib.js +282 -0
- package/apps/web/public/pages/applications.js +98 -0
- package/apps/web/public/pages/documents.js +446 -0
- package/apps/web/public/pages/home.js +263 -0
- package/apps/web/public/pages/interview.js +230 -0
- package/apps/web/public/pages/jobs.js +494 -0
- package/apps/web/public/pages/profile.js +576 -0
- package/apps/web/public/pages/settings.js +233 -0
- package/apps/web/public/styles.css +426 -0
- package/apps/web/src/exports.ts +68 -0
- package/apps/web/src/http.ts +180 -0
- package/apps/web/src/index.ts +49 -0
- package/apps/web/src/info.ts +50 -0
- package/apps/web/src/routes.ts +350 -0
- package/apps/web/src/security.ts +102 -0
- package/apps/web/src/server.ts +141 -0
- package/apps/web/src/settings.ts +88 -0
- package/bin/careermate.mjs +74 -0
- package/dist/careermate.mcpb +0 -0
- package/dist/install-page/index.html +474 -0
- package/dist/install-page/style.css +391 -0
- package/dist/install-page/vercel.json +20 -0
- package/dist/mcp-smoke.err +3 -0
- package/dist/mcp.mjs +23704 -0
- package/dist/mcpb-stage/README.md +219 -0
- package/dist/mcpb-stage/dist/install-page/index.html +434 -0
- package/dist/mcpb-stage/dist/install-page/style.css +407 -0
- package/dist/mcpb-stage/dist/install-page/vercel.json +20 -0
- package/dist/mcpb-stage/dist/mcp.mjs +23704 -0
- package/dist/mcpb-stage/dist/public/app.js +118 -0
- package/dist/mcpb-stage/dist/public/fonts/PretendardVariable.woff2 +0 -0
- package/dist/mcpb-stage/dist/public/index.html +41 -0
- package/dist/mcpb-stage/dist/public/lib.js +282 -0
- package/dist/mcpb-stage/dist/public/pages/applications.js +98 -0
- package/dist/mcpb-stage/dist/public/pages/documents.js +446 -0
- package/dist/mcpb-stage/dist/public/pages/home.js +263 -0
- package/dist/mcpb-stage/dist/public/pages/interview.js +230 -0
- package/dist/mcpb-stage/dist/public/pages/jobs.js +494 -0
- package/dist/mcpb-stage/dist/public/pages/profile.js +576 -0
- package/dist/mcpb-stage/dist/public/pages/settings.js +233 -0
- package/dist/mcpb-stage/dist/public/styles.css +420 -0
- package/dist/mcpb-stage/dist/web.mjs +7240 -0
- package/dist/mcpb-stage/manifest.json +40 -0
- package/dist/public/app.js +118 -0
- package/dist/public/fonts/PretendardVariable.woff2 +0 -0
- package/dist/public/index.html +41 -0
- package/dist/public/lib.js +282 -0
- package/dist/public/pages/applications.js +98 -0
- package/dist/public/pages/documents.js +446 -0
- package/dist/public/pages/home.js +263 -0
- package/dist/public/pages/interview.js +230 -0
- package/dist/public/pages/jobs.js +494 -0
- package/dist/public/pages/profile.js +576 -0
- package/dist/public/pages/settings.js +233 -0
- package/dist/public/styles.css +426 -0
- package/dist/web.mjs +7240 -0
- package/docs/ARCHITECTURE.md +208 -0
- package/docs/CHANGES_V1.md +103 -0
- package/docs/DATA_MODEL.md +460 -0
- package/docs/DECISIONS.md +277 -0
- package/docs/DEMO.md +242 -0
- package/docs/INSTALL.md +148 -0
- package/docs/INSTALL_AND_USAGE.md +99 -0
- package/docs/MCP_TOOLS.md +233 -0
- package/docs/ROADMAP.md +134 -0
- package/docs/START_WORKFLOW.md +125 -0
- package/docs/SUPPORTED_AI_APPS.md +60 -0
- package/docs/TODO.md +57 -0
- package/docs/UX_NOTES.md +247 -0
- package/docs/WORKFLOWS.md +200 -0
- package/install-page/index.html +474 -0
- package/install-page/style.css +391 -0
- package/install-page/vercel.json +20 -0
- package/package.json +68 -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 +146 -0
- package/packages/core/src/summary.ts +104 -0
- package/packages/db/src/connection.ts +46 -0
- package/packages/db/src/index.ts +22 -0
- package/packages/db/src/paths.ts +41 -0
- package/packages/db/src/repositories.ts +828 -0
- package/packages/db/src/runtime.ts +58 -0
- package/packages/db/src/schema.ts +189 -0
- package/packages/exporters/src/html.ts +113 -0
- package/packages/exporters/src/index.ts +364 -0
- package/packages/exporters/src/markdown.ts +178 -0
- package/packages/mcp-tools/src/bridge.ts +83 -0
- package/packages/mcp-tools/src/index.ts +8 -0
- package/packages/mcp-tools/src/result.ts +49 -0
- package/packages/mcp-tools/src/tools.ts +455 -0
- package/packages/parsers/src/html.ts +86 -0
- package/packages/parsers/src/index.ts +228 -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 +53 -0
- package/packages/shared/src/enums.ts +103 -0
- package/packages/shared/src/index.ts +18 -0
- package/packages/shared/src/schemas.ts +398 -0
- package/packages/workflows/src/definitions.ts +107 -0
- package/packages/workflows/src/index.ts +39 -0
- package/scripts/build-dist.mjs +62 -0
- package/scripts/build-mcpb.mjs +70 -0
- package/scripts/doctor.ts +81 -0
- package/scripts/init.ts +342 -0
- package/scripts/mcp-probe.ts +55 -0
- package/scripts/migrate.ts +6 -0
- package/scripts/run.mjs +33 -0
- package/scripts/seed.ts +129 -0
- package/scripts/test.ts +117 -0
- package/scripts/ui-smoke.ts +73 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": "0.3",
|
|
3
|
+
"name": "careermate",
|
|
4
|
+
"display_name": "CareerMate",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"description": "내 AI를 커리어 비서로 — 이력서·자기소개서·지원 현황을 이 컴퓨터에만 저장하고 MCP로 연결하는 로컬 커리어 관리 도구.",
|
|
7
|
+
"long_description": "CareerMate는 LLM을 포함하지 않습니다. 사용자의 Claude가 MCP 도구로 로컬 커리어 데이터(프로필·이력서·자기소개서·공고·지원 현황)를 읽고 씁니다. 모든 데이터는 ~/.careermate 에만 저장되며 외부로 전송되지 않습니다. 자기소개서 작성 시에는 'AI 티 안 나는 글쓰기' 규칙을 함께 적용합니다.",
|
|
8
|
+
"author": {
|
|
9
|
+
"name": "CareerMate"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/osntak/CareerFlow",
|
|
12
|
+
"documentation": "https://github.com/osntak/CareerFlow/blob/main/docs/MCP_TOOLS.md",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/osntak/CareerFlow"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"keywords": ["career", "resume", "cover-letter", "korean", "mcp", "local-first"],
|
|
19
|
+
"server": {
|
|
20
|
+
"type": "node",
|
|
21
|
+
"entry_point": "dist/mcp.mjs",
|
|
22
|
+
"mcp_config": {
|
|
23
|
+
"command": "node",
|
|
24
|
+
"args": ["${__dirname}/dist/mcp.mjs"]
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"tools": [
|
|
28
|
+
{ "name": "get_application_context", "description": "공고 분석·자소서 작성 전 사용자 맥락을 한 번에 가져오기" },
|
|
29
|
+
{ "name": "save_fit_analysis", "description": "공고-사용자 적합도 분석 결과 저장" },
|
|
30
|
+
{ "name": "save_cover_letter_version", "description": "자기소개서 버전 저장" },
|
|
31
|
+
{ "name": "get_writing_style_guide", "description": "AI 티 안 나는 글쓰기 규칙" },
|
|
32
|
+
{ "name": "open_dashboard", "description": "로컬 대시보드 열기" }
|
|
33
|
+
],
|
|
34
|
+
"compatibility": {
|
|
35
|
+
"platforms": ["win32", "darwin", "linux"],
|
|
36
|
+
"runtimes": {
|
|
37
|
+
"node": ">=22.5.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
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 moduleCache = new Map();
|
|
23
|
+
|
|
24
|
+
function parseHash() {
|
|
25
|
+
const raw = location.hash.replace(/^#\/?/, '');
|
|
26
|
+
const segments = raw.split('/').filter(Boolean);
|
|
27
|
+
return { page: segments[0] || 'home', params: segments.slice(1) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function renderNav(counts = {}) {
|
|
31
|
+
clear(navEl);
|
|
32
|
+
navEl.append(el('div', { class: 'nav__label' }, '워크스페이스'));
|
|
33
|
+
const { page } = parseHash();
|
|
34
|
+
for (const r of ROUTES) {
|
|
35
|
+
if (r.id === 'settings') navEl.append(el('div', { class: 'nav__label' }, '시스템'));
|
|
36
|
+
const count = r.countKey ? counts[r.countKey] : null;
|
|
37
|
+
navEl.append(el('a', {
|
|
38
|
+
class: `nav__item${page === r.id ? ' is-active' : ''}`,
|
|
39
|
+
href: `#/${r.id}`,
|
|
40
|
+
}, icon(r.icon), el('span', {}, r.label),
|
|
41
|
+
count ? el('span', { class: 'nav__badge tnum' }, String(count)) : null));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function refreshNav() {
|
|
46
|
+
try {
|
|
47
|
+
const summary = await get('/api/summary');
|
|
48
|
+
renderNav(summary.counts);
|
|
49
|
+
} catch {
|
|
50
|
+
renderNav();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function loadModule(path) {
|
|
55
|
+
if (moduleCache.has(path)) return moduleCache.get(path);
|
|
56
|
+
const mod = await import(path);
|
|
57
|
+
moduleCache.set(path, mod);
|
|
58
|
+
return mod;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let renderToken = 0;
|
|
62
|
+
async function route() {
|
|
63
|
+
const token = ++renderToken;
|
|
64
|
+
const { page, params } = parseHash();
|
|
65
|
+
const def = ROUTES.find((r) => r.id === page) || ROUTES[0];
|
|
66
|
+
|
|
67
|
+
// active nav state
|
|
68
|
+
navEl.querySelectorAll('.nav__item').forEach((n) => n.classList.toggle('is-active', n.getAttribute('href') === `#/${def.id}`));
|
|
69
|
+
titleEl.textContent = def.label;
|
|
70
|
+
clear(actionsEl);
|
|
71
|
+
view.scrollTop = 0;
|
|
72
|
+
window.scrollTo(0, 0);
|
|
73
|
+
|
|
74
|
+
const ctx = {
|
|
75
|
+
view,
|
|
76
|
+
params,
|
|
77
|
+
setTitle: (t) => { titleEl.textContent = t; },
|
|
78
|
+
setActions: (nodes) => { clear(actionsEl); [].concat(nodes).filter(Boolean).forEach((n) => actionsEl.append(n)); },
|
|
79
|
+
navigate,
|
|
80
|
+
refreshNav,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
clear(view);
|
|
84
|
+
view.append(Spinner());
|
|
85
|
+
try {
|
|
86
|
+
const mod = await loadModule(def.module);
|
|
87
|
+
if (token !== renderToken) return; // a newer navigation superseded this one
|
|
88
|
+
clear(view);
|
|
89
|
+
await mod.render(ctx);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (token !== renderToken) return;
|
|
92
|
+
clear(view);
|
|
93
|
+
view.append(el('div', { class: 'card' }, el('div', { class: 'card__body' },
|
|
94
|
+
el('h3', { style: { marginBottom: '8px' } }, '페이지를 불러오지 못했습니다'),
|
|
95
|
+
el('p', { class: 'text-secondary' }, err instanceof Error ? err.message : String(err)))));
|
|
96
|
+
toastError(err);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function initTheme() {
|
|
101
|
+
const saved = localStorage.getItem('cf-theme');
|
|
102
|
+
if (saved === 'light' || saved === 'dark') document.documentElement.dataset.theme = saved;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function boot() {
|
|
106
|
+
initTheme();
|
|
107
|
+
// Show data location in the footer (privacy: user can always see where data lives).
|
|
108
|
+
try {
|
|
109
|
+
const h = await get('/api/health');
|
|
110
|
+
document.getElementById('foot-path').textContent = h.data_dir;
|
|
111
|
+
document.getElementById('foot-path').title = h.data_dir;
|
|
112
|
+
} catch { /* ignore */ }
|
|
113
|
+
await refreshNav();
|
|
114
|
+
window.addEventListener('hashchange', route);
|
|
115
|
+
await route();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
boot();
|
|
Binary file
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
<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='%234f46e5'/><text x='12' y='17' font-size='14' text-anchor='middle' fill='white' font-family='sans-serif' font-weight='bold'>C</text></svg>" />
|
|
9
|
+
<link rel="preload" href="/fonts/PretendardVariable.woff2" as="font" type="font/woff2" crossorigin />
|
|
10
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div class="app">
|
|
14
|
+
<aside class="sidebar">
|
|
15
|
+
<div class="sidebar__brand">
|
|
16
|
+
<div class="sidebar__logo">C</div>
|
|
17
|
+
<div>
|
|
18
|
+
<div class="sidebar__title">CareerMate</div>
|
|
19
|
+
<div class="sidebar__subtitle">내 커리어 흐름 관리</div>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
<nav class="nav" id="nav"></nav>
|
|
23
|
+
<div class="sidebar__foot">
|
|
24
|
+
<div><span class="dot"></span><span id="foot-status">로컬에서 실행 중</span></div>
|
|
25
|
+
<div id="foot-path" class="truncate" style="margin-top:4px"></div>
|
|
26
|
+
</div>
|
|
27
|
+
</aside>
|
|
28
|
+
|
|
29
|
+
<div class="main">
|
|
30
|
+
<header class="topbar">
|
|
31
|
+
<div class="topbar__title" id="topbar-title">CareerMate</div>
|
|
32
|
+
<div class="topbar__spacer"></div>
|
|
33
|
+
<div id="topbar-actions" class="flex gap-2"></div>
|
|
34
|
+
</header>
|
|
35
|
+
<main id="view" class="view" aria-live="polite"></main>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<script type="module" src="/app.js"></script>
|
|
40
|
+
</body>
|
|
41
|
+
</html>
|
|
@@ -0,0 +1,282 @@
|
|
|
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
|
+
* Create an element. `props` may include: class, id, type, value, placeholder,
|
|
15
|
+
* href, title, disabled, dataset:{}, style:{}, attrs:{}, and on* event handlers
|
|
16
|
+
* (onClick, onInput, onChange, onSubmit, onKeydown). `children` are appended:
|
|
17
|
+
* strings/numbers become safe text nodes; nodes are appended as-is; null/false
|
|
18
|
+
* are ignored; arrays are flattened.
|
|
19
|
+
*/
|
|
20
|
+
export function el(tag, props = {}, ...children) {
|
|
21
|
+
const node = document.createElement(tag);
|
|
22
|
+
for (const [k, v] of Object.entries(props || {})) {
|
|
23
|
+
if (v == null || v === false) continue;
|
|
24
|
+
if (k === 'class') node.className = v;
|
|
25
|
+
else if (k === 'html') node.innerHTML = v; // trusted callers only
|
|
26
|
+
else if (k === 'text') node.textContent = v;
|
|
27
|
+
else if (k === 'dataset') Object.assign(node.dataset, v);
|
|
28
|
+
else if (k === 'style') Object.assign(node.style, v);
|
|
29
|
+
else if (k === 'attrs') for (const [a, val] of Object.entries(v)) node.setAttribute(a, val);
|
|
30
|
+
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
|
|
31
|
+
else if (k in node) { try { node[k] = v; } catch { node.setAttribute(k, v); } }
|
|
32
|
+
else node.setAttribute(k, v);
|
|
33
|
+
}
|
|
34
|
+
appendChildren(node, children);
|
|
35
|
+
return node;
|
|
36
|
+
}
|
|
37
|
+
function appendChildren(node, children) {
|
|
38
|
+
for (const c of children.flat(Infinity)) {
|
|
39
|
+
if (c == null || c === false || c === true) continue;
|
|
40
|
+
node.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function frag(...children) { const f = document.createDocumentFragment(); appendChildren(f, children); return f; }
|
|
44
|
+
export function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); return node; }
|
|
45
|
+
export function mount(node, ...children) { clear(node); appendChildren(node, children); return node; }
|
|
46
|
+
|
|
47
|
+
/* ------------------------------------------------------------------- icons */
|
|
48
|
+
// Minimal, consistent 1.6px stroke icon set (Lucide-style). Trusted strings.
|
|
49
|
+
const ICONS = {
|
|
50
|
+
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>',
|
|
51
|
+
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="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M3 12h18"/>',
|
|
53
|
+
layers: '<path d="m12 3 9 5-9 5-9-5 9-5Z"/><path d="m3 13 9 5 9-5"/>',
|
|
54
|
+
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
|
+
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"/>',
|
|
56
|
+
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"/>',
|
|
57
|
+
plus: '<path d="M12 5v14M5 12h14"/>',
|
|
58
|
+
edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
|
59
|
+
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"/>',
|
|
60
|
+
copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>',
|
|
61
|
+
download: '<path d="M12 3v12"/><path d="m7 11 5 4 5-4"/><path d="M5 21h14"/>',
|
|
62
|
+
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"/>',
|
|
63
|
+
check: '<path d="m20 6-11 11-5-5"/>',
|
|
64
|
+
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"/>',
|
|
65
|
+
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
|
66
|
+
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"/>',
|
|
67
|
+
lock: '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
|
|
68
|
+
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/>',
|
|
69
|
+
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"/>',
|
|
70
|
+
target: '<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r="0.5"/>',
|
|
71
|
+
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>',
|
|
72
|
+
chevronRight: '<path d="m9 6 6 6-6 6"/>',
|
|
73
|
+
};
|
|
74
|
+
/** Returns an <svg> element for the given icon name. */
|
|
75
|
+
export function icon(name, cls = '') {
|
|
76
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
77
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
78
|
+
// Default intrinsic size so bare icons never balloon to fill their flex parent.
|
|
79
|
+
// CSS rules (.nav__item svg, .btn svg, .empty__icon svg, …) override this freely.
|
|
80
|
+
svg.setAttribute('width', '18');
|
|
81
|
+
svg.setAttribute('height', '18');
|
|
82
|
+
svg.setAttribute('fill', 'none');
|
|
83
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
84
|
+
svg.setAttribute('stroke-width', '1.7');
|
|
85
|
+
svg.setAttribute('stroke-linecap', 'round');
|
|
86
|
+
svg.setAttribute('stroke-linejoin', 'round');
|
|
87
|
+
if (cls) svg.setAttribute('class', cls);
|
|
88
|
+
svg.innerHTML = ICONS[name] || ICONS.info;
|
|
89
|
+
return svg;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* -------------------------------------------------------------- API client */
|
|
93
|
+
const TOKEN = document.querySelector('meta[name="careermate-token"]')?.content || '';
|
|
94
|
+
|
|
95
|
+
/** Call the JSON API. Throws Error(message) on non-2xx. */
|
|
96
|
+
export async function api(method, path, body) {
|
|
97
|
+
const res = await fetch(path, {
|
|
98
|
+
method,
|
|
99
|
+
headers: {
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
...(method !== 'GET' ? { 'x-careermate-token': TOKEN } : {}),
|
|
102
|
+
},
|
|
103
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
104
|
+
});
|
|
105
|
+
const text = await res.text();
|
|
106
|
+
const data = text ? JSON.parse(text) : {};
|
|
107
|
+
if (!res.ok) throw new Error(data.error || `요청 실패 (${res.status})`);
|
|
108
|
+
return data;
|
|
109
|
+
}
|
|
110
|
+
export const get = (p) => api('GET', p);
|
|
111
|
+
export const post = (p, b) => api('POST', p, b);
|
|
112
|
+
export const put = (p, b) => api('PUT', p, b);
|
|
113
|
+
export const del = (p) => api('DELETE', p);
|
|
114
|
+
|
|
115
|
+
/* ------------------------------------------------------------- formatters */
|
|
116
|
+
export function fmtDate(iso) {
|
|
117
|
+
if (!iso) return '';
|
|
118
|
+
const d = new Date(iso);
|
|
119
|
+
if (isNaN(d)) return iso;
|
|
120
|
+
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
|
|
121
|
+
}
|
|
122
|
+
export function fmtRelative(iso) {
|
|
123
|
+
if (!iso) return '';
|
|
124
|
+
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
|
125
|
+
if (diff < 60) return '방금 전';
|
|
126
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`;
|
|
127
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`;
|
|
128
|
+
if (diff < 604800) return `${Math.floor(diff / 86400)}일 전`;
|
|
129
|
+
return fmtDate(iso);
|
|
130
|
+
}
|
|
131
|
+
export function scoreClass(score) {
|
|
132
|
+
if (score == null) return 'muted';
|
|
133
|
+
if (score >= 75) return 'score-strong';
|
|
134
|
+
if (score >= 50) return 'score-mid';
|
|
135
|
+
return 'score-weak';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* ---------------------------------------------------------------- routing */
|
|
139
|
+
export function navigate(hash) { location.hash = hash.startsWith('#') ? hash : '#' + hash; }
|
|
140
|
+
|
|
141
|
+
/* ------------------------------------------------------------- components */
|
|
142
|
+
export function Badge(status, label) {
|
|
143
|
+
return el('span', { class: `badge badge--${status}` }, el('span', { class: 'dot' }), label);
|
|
144
|
+
}
|
|
145
|
+
export function Btn(label, { icon: ic, variant = '', sm, onClick, type, disabled, title } = {}) {
|
|
146
|
+
const cls = ['btn', variant && `btn--${variant}`, sm && 'btn--sm'].filter(Boolean).join(' ');
|
|
147
|
+
return el('button', { class: cls, onClick, type: type || 'button', disabled, title }, ic && icon(ic), label && el('span', {}, label));
|
|
148
|
+
}
|
|
149
|
+
export function IconBtn(name, { onClick, title, variant = 'ghost' } = {}) {
|
|
150
|
+
return el('button', { class: `btn btn--${variant} icon-btn`, onClick, title, attrs: { 'aria-label': title || name } }, icon(name));
|
|
151
|
+
}
|
|
152
|
+
export function Card({ title, sub, actions, body, clickable, onClick } = {}) {
|
|
153
|
+
const head = (title || actions) && el('div', { class: 'card__head' },
|
|
154
|
+
title && el('h3', {}, title),
|
|
155
|
+
sub && el('span', { class: 'sub' }, sub),
|
|
156
|
+
actions && el('div', { class: 'right' }, ...[].concat(actions)),
|
|
157
|
+
);
|
|
158
|
+
return el('div', { class: `card${clickable ? ' is-clickable' : ''}`, onClick },
|
|
159
|
+
head, el('div', { class: 'card__body' }, ...[].concat(body || [])));
|
|
160
|
+
}
|
|
161
|
+
export function Stat({ label, value, hint, iconName }) {
|
|
162
|
+
return el('div', { class: 'stat' },
|
|
163
|
+
el('div', { class: 'stat__label' }, iconName && icon(iconName), label),
|
|
164
|
+
el('div', { class: 'stat__value tnum' }, value),
|
|
165
|
+
hint && el('div', { class: 'stat__hint' }, hint),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
export function EmptyState({ iconName = 'inbox', title, body, action }) {
|
|
169
|
+
return el('div', { class: 'empty' },
|
|
170
|
+
el('div', { class: 'empty__icon' }, icon(iconName)),
|
|
171
|
+
el('h3', {}, title),
|
|
172
|
+
body && el('p', {}, body),
|
|
173
|
+
action,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
export function Chips(items, { accent } = {}) {
|
|
177
|
+
return el('div', { class: 'chips' }, ...(items || []).map((t) => el('span', { class: `chip${accent ? ' chip--accent' : ''}` }, t)));
|
|
178
|
+
}
|
|
179
|
+
/** Dense list row: leading slot → title + muted sub → right-aligned trailing. */
|
|
180
|
+
export function ListRow({ leading, title, sub, trailing, onClick } = {}) {
|
|
181
|
+
return el('div', { class: `list-row${onClick ? ' is-clickable' : ''}`, onClick },
|
|
182
|
+
leading && el('div', { class: 'list-row__lead' }, leading),
|
|
183
|
+
el('div', { class: 'list-row__main' },
|
|
184
|
+
el('div', { class: 'list-row__title' }, title),
|
|
185
|
+
sub && el('div', { class: 'list-row__sub' }, sub)),
|
|
186
|
+
trailing && el('div', { class: 'list-row__trail' }, ...[].concat(trailing)));
|
|
187
|
+
}
|
|
188
|
+
/** Onboarding checklist row: hollow dot (todo, links via onClick) or green check (done, struck through). */
|
|
189
|
+
export function CheckRow({ done, label, onClick } = {}) {
|
|
190
|
+
return el('div', { class: `check-row${done ? ' is-done' : ''}` },
|
|
191
|
+
done ? icon('check', 'check-row__icon') : el('span', { class: 'check-row__dot' }),
|
|
192
|
+
el('span', { class: `check-row__label${!done && onClick ? ' is-link' : ''}`, onClick: done ? null : onClick }, label));
|
|
193
|
+
}
|
|
194
|
+
export function Field(label, control, hint) {
|
|
195
|
+
return el('div', { class: 'field' }, label && el('label', {}, label), control, hint && el('div', { class: 'hint' }, hint));
|
|
196
|
+
}
|
|
197
|
+
export function Input(props = {}) { return el('input', { class: 'input', ...props }); }
|
|
198
|
+
export function Textarea(props = {}) { return el('textarea', { class: 'textarea', ...props }); }
|
|
199
|
+
export function Select(options, props = {}) {
|
|
200
|
+
return el('select', { class: 'select', ...props }, ...options.map((o) => el('option', { value: o.value, selected: o.selected }, o.label)));
|
|
201
|
+
}
|
|
202
|
+
export function PageHead({ title, desc, actions }) {
|
|
203
|
+
return el('div', { class: 'page-head' },
|
|
204
|
+
el('div', { class: 'page-head__text' }, el('h1', {}, title), desc && el('p', {}, desc)),
|
|
205
|
+
actions && el('div', { class: 'page-head__actions' }, ...[].concat(actions)),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
export function Spinner() { return el('div', { class: 'view' }, el('div', { class: 'card' }, el('div', { class: 'card__body' }, el('div', { class: 'skeleton', style: { height: '120px' } })))); }
|
|
209
|
+
|
|
210
|
+
/* ------------------------------------------------------------------ toast */
|
|
211
|
+
let toastRoot;
|
|
212
|
+
export function toast(message, { title, type = 'default', timeout = 3800 } = {}) {
|
|
213
|
+
if (!toastRoot) { toastRoot = el('div', { class: 'toasts' }); document.body.append(toastRoot); }
|
|
214
|
+
const t = el('div', { class: `toast toast--${type}` },
|
|
215
|
+
title && el('div', { class: 'toast__title' }, title),
|
|
216
|
+
el('div', { class: 'toast__body' }, message));
|
|
217
|
+
toastRoot.append(t);
|
|
218
|
+
setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .25s'; setTimeout(() => t.remove(), 260); }, timeout);
|
|
219
|
+
}
|
|
220
|
+
export function toastError(e) { toast(e instanceof Error ? e.message : String(e), { title: '오류', type: 'error' }); }
|
|
221
|
+
export function toastOk(msg) { toast(msg, { type: 'success' }); }
|
|
222
|
+
|
|
223
|
+
/* ------------------------------------------------------------------ modal */
|
|
224
|
+
let modalRoot;
|
|
225
|
+
function ensureModalRoot() {
|
|
226
|
+
if (!modalRoot) {
|
|
227
|
+
modalRoot = el('div', { class: 'modal-root' });
|
|
228
|
+
document.body.append(modalRoot);
|
|
229
|
+
modalRoot.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
|
|
230
|
+
}
|
|
231
|
+
return modalRoot;
|
|
232
|
+
}
|
|
233
|
+
export function closeModal() { if (modalRoot) { modalRoot.classList.remove('is-open'); clear(modalRoot); } }
|
|
234
|
+
/**
|
|
235
|
+
* Open a modal. `render(close)` returns the modal body content. Provide title,
|
|
236
|
+
* optional footer buttons via `footer(close)`. Returns nothing; call closeModal().
|
|
237
|
+
*/
|
|
238
|
+
export function openModal({ title, body, footer, size }) {
|
|
239
|
+
const root = ensureModalRoot();
|
|
240
|
+
const close = closeModal;
|
|
241
|
+
const closeBtn = IconBtn('plus', { title: '닫기', onClick: close }); // rotated to look like ×
|
|
242
|
+
closeBtn.style.transform = 'rotate(45deg)';
|
|
243
|
+
const modal = el('div', { class: `modal${size === 'lg' ? ' modal--lg' : ''}` },
|
|
244
|
+
el('div', { class: 'modal__head' }, el('h3', {}, title), el('div', { class: 'topbar__spacer' }), closeBtn),
|
|
245
|
+
);
|
|
246
|
+
const bodyNode = el('div', { class: 'modal__body' });
|
|
247
|
+
appendChildren(bodyNode, [typeof body === 'function' ? body(close) : body]);
|
|
248
|
+
modal.append(bodyNode);
|
|
249
|
+
if (footer) modal.append(el('div', { class: 'modal__foot' }, ...[].concat(footer(close))));
|
|
250
|
+
mount(root, el('div', { class: 'modal__scrim', onClick: close }), modal);
|
|
251
|
+
root.classList.add('is-open');
|
|
252
|
+
setTimeout(() => bodyNode.querySelector('input,textarea,select,button')?.focus(), 30);
|
|
253
|
+
}
|
|
254
|
+
/** Confirmation dialog. Returns a Promise<boolean>. */
|
|
255
|
+
export function confirmDialog({ title = '확인', message, confirmLabel = '확인', danger } = {}) {
|
|
256
|
+
return new Promise((resolve) => {
|
|
257
|
+
openModal({
|
|
258
|
+
title,
|
|
259
|
+
body: el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } }, message),
|
|
260
|
+
footer: (close) => [
|
|
261
|
+
Btn('취소', { onClick: () => { close(); resolve(false); } }),
|
|
262
|
+
Btn(confirmLabel, { variant: danger ? 'danger' : 'primary', onClick: () => { close(); resolve(true); } }),
|
|
263
|
+
],
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* ---------------------------------------------------------------- utility */
|
|
269
|
+
export async function copyText(text) {
|
|
270
|
+
try { await navigator.clipboard.writeText(text); toastOk('클립보드에 복사했습니다.'); }
|
|
271
|
+
catch { toast('복사에 실패했습니다. 직접 선택해 복사해 주세요.', { type: 'error' }); }
|
|
272
|
+
}
|
|
273
|
+
/** Trigger a file download via a GET endpoint (no token needed; read-only). */
|
|
274
|
+
export function downloadUrl(url) {
|
|
275
|
+
const a = el('a', { href: url, download: '' });
|
|
276
|
+
document.body.append(a); a.click(); a.remove();
|
|
277
|
+
}
|
|
278
|
+
export function debounce(fn, ms = 250) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
|
|
279
|
+
|
|
280
|
+
/** Shared meta (statuses, document kinds) loaded once. */
|
|
281
|
+
let _meta = null;
|
|
282
|
+
export async function meta() { if (!_meta) _meta = await get('/api/meta'); return _meta; }
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Applications — every application grouped by status in a calm VERTICAL list
|
|
2
|
+
// (no horizontal scroll). Change a status inline to move an item between groups.
|
|
3
|
+
import {
|
|
4
|
+
el, get, put, navigate, EmptyState, Btn, ListRow,
|
|
5
|
+
Select, scoreClass, mount, toast, toastOk, toastError, meta,
|
|
6
|
+
} from '/lib.js';
|
|
7
|
+
|
|
8
|
+
// status code → theme colour (matches the badge--{status} hues)
|
|
9
|
+
const STATUS_COLOR = {
|
|
10
|
+
draft: 'var(--slate)', planned: 'var(--blue)', applied: 'var(--violet)',
|
|
11
|
+
document_passed: 'var(--teal)', interview: 'var(--amber)',
|
|
12
|
+
final_passed: 'var(--green)', on_hold: 'var(--slate)', rejected: 'var(--red)',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function render(ctx) {
|
|
16
|
+
ctx.setActions([]);
|
|
17
|
+
const data = await get('/api/applications');
|
|
18
|
+
const applications = data.applications || [];
|
|
19
|
+
const boardOrder = data.board_order || [];
|
|
20
|
+
|
|
21
|
+
if (!applications.length) {
|
|
22
|
+
mount(ctx.view, el('div', { class: 'stack-4' }, EmptyState({
|
|
23
|
+
iconName: 'layers',
|
|
24
|
+
title: '아직 지원 내역이 없어요',
|
|
25
|
+
body: '공고를 추가하면 지원이 상태별로 정리됩니다.',
|
|
26
|
+
action: Btn('공고 보러 가기', { icon: 'briefcase', variant: 'primary', onClick: () => navigate('/jobs') }),
|
|
27
|
+
})));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Status labels: board_order is authoritative for ordering; fall back to meta() labels.
|
|
32
|
+
const labelByCode = {};
|
|
33
|
+
for (const a of applications) if (a.status && a.status_label) labelByCode[a.status] = a.status_label;
|
|
34
|
+
let metaStatuses = [];
|
|
35
|
+
try { metaStatuses = (await meta()).statuses || []; } catch { /* labels fall back to codes */ }
|
|
36
|
+
for (const s of metaStatuses) if (!labelByCode[s.value]) labelByCode[s.value] = s.label;
|
|
37
|
+
const statusOptions = boardOrder.map((code) => ({ value: code, label: labelByCode[code] || code }));
|
|
38
|
+
|
|
39
|
+
// Group by status.
|
|
40
|
+
const byStatus = {};
|
|
41
|
+
for (const app of applications) (byStatus[app.status] || (byStatus[app.status] = [])).push(app);
|
|
42
|
+
|
|
43
|
+
const wrap = el('div', { class: 'stack-4' });
|
|
44
|
+
for (const code of boardOrder) {
|
|
45
|
+
const apps = byStatus[code];
|
|
46
|
+
if (apps && apps.length) wrap.append(StatusGroup(code, apps));
|
|
47
|
+
}
|
|
48
|
+
mount(ctx.view, wrap);
|
|
49
|
+
|
|
50
|
+
function StatusGroup(code, apps) {
|
|
51
|
+
return el('div', {},
|
|
52
|
+
el('div', { class: 'app-group__head' },
|
|
53
|
+
el('span', { class: 'app-group__dot', style: { background: STATUS_COLOR[code] || 'var(--text-tertiary)' } }),
|
|
54
|
+
el('span', { class: 'app-group__title' }, labelByCode[code] || code),
|
|
55
|
+
el('span', { class: 'app-group__count tnum' }, `${apps.length}건`)),
|
|
56
|
+
el('div', {}, ...apps.map(AppRow)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function AppRow(app) {
|
|
60
|
+
const job = app.job || {};
|
|
61
|
+
const sel = Select(
|
|
62
|
+
statusOptions.map((o) => ({ ...o, selected: o.value === app.status })),
|
|
63
|
+
{
|
|
64
|
+
class: 'select select--sm row-action',
|
|
65
|
+
title: '상태 변경',
|
|
66
|
+
onClick: (e) => e.stopPropagation(),
|
|
67
|
+
onChange: (e) => { e.stopPropagation(); changeStatus(app, e.target.value, e.target); },
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
return ListRow({
|
|
71
|
+
title: job.company || '—',
|
|
72
|
+
sub: job.position || '',
|
|
73
|
+
trailing: [
|
|
74
|
+
app.fit_score != null
|
|
75
|
+
? el('span', { class: `strong tnum ${scoreClass(app.fit_score)}` }, `${app.fit_score}점`)
|
|
76
|
+
: null,
|
|
77
|
+
sel,
|
|
78
|
+
],
|
|
79
|
+
onClick: () => navigate(`/jobs/${app.job_id}`),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function changeStatus(app, newValue, selectEl) {
|
|
84
|
+
if (!newValue || newValue === app.status) return;
|
|
85
|
+
selectEl.disabled = true;
|
|
86
|
+
try {
|
|
87
|
+
const res = await put(`/api/applications/${app.job_id}/status`, { status: newValue });
|
|
88
|
+
toastOk('지원 상태를 변경했어요.');
|
|
89
|
+
if (res && res.hint) toast(res.hint, { title: '면접 준비', type: 'default' });
|
|
90
|
+
await ctx.refreshNav();
|
|
91
|
+
await render(ctx); // refetch + re-render so the item moves to its new group
|
|
92
|
+
} catch (err) {
|
|
93
|
+
toastError(err);
|
|
94
|
+
selectEl.value = app.status; // revert on failure
|
|
95
|
+
selectEl.disabled = false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|