careermate 0.1.1 → 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 +12 -10
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/apps/mcp/src/index.ts +4 -5
- 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/index.html +10 -2
- package/apps/web/public/lib.js +134 -22
- package/apps/web/public/pages/applications.js +2 -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 +15 -10
- package/apps/web/public/styles.css +56 -2
- package/apps/web/src/http.ts +31 -4
- package/apps/web/src/info.ts +3 -3
- package/apps/web/src/routes.ts +2 -1
- package/apps/web/src/server.ts +1 -4
- package/dist/doctor.mjs +72607 -611
- 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/index.html +31 -70
- package/dist/install-page/landing.js +54 -0
- package/dist/install-page/start.html +13 -36
- package/dist/install-page/style.css +34 -1
- package/dist/mcp.mjs +73936 -1976
- package/dist/migrate.mjs +17 -10
- package/dist/pdf.worker.min.mjs +28 -0
- package/dist/public/app.js +34 -2
- package/dist/public/index.html +10 -2
- package/dist/public/lib.js +134 -22
- package/dist/public/pages/applications.js +2 -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 +15 -10
- package/dist/public/styles.css +56 -2
- package/dist/web.mjs +39631 -244
- package/docs/ARCHITECTURE.md +5 -5
- package/docs/DECISIONS.md +1 -1
- package/docs/DEMO.md +3 -3
- package/docs/MCP_TOOLS.md +49 -9
- package/docs/ROADMAP.md +8 -7
- package/docs/START_WORKFLOW.md +13 -7
- package/docs/TODO.md +1 -1
- package/docs/WORKFLOWS.md +7 -5
- package/docs/superpowers/specs/2026-06-16-demo-dashboard-deploy-design.md +64 -0
- package/package.json +6 -2
- package/packages/db/src/paths.ts +11 -0
- 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 +193 -70
- 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/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 +20 -1
- package/scripts/doctor.ts +16 -3
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
## 주요 기능
|
|
28
28
|
|
|
29
|
-
- **AI와 대화로 모든 작업** — MCP 도구
|
|
29
|
+
- **AI와 대화로 모든 작업** — MCP 도구 31개로 온보딩·프로필·이력서·자소서·공고·핏 분석·지원 상태·면접 준비·AI 티 안 나는 글쓰기까지 처리.
|
|
30
30
|
- **지원 상태 8단계 관리** — `draft`(작성 중) · `planned`(지원 예정) · `applied`(지원 완료) · `document_passed`(서류 합격) · `interview`(면접 진행) · `final_passed`(최종 합격) · `rejected`(불합격) · `on_hold`(보류). `document_passed` 이상에서 면접 준비가 해금됩니다.
|
|
31
31
|
- **자기소개서 버전 관리** — 공고별로 자소서 버전을 쌓고 타임라인으로 비교, 파일로 내보내기.
|
|
32
32
|
- **채용공고 파싱** — 붙여넣은 공고 텍스트를 구조화해 저장.
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
| 프로세스 | 실행 | 역할 |
|
|
43
43
|
| --- | --- | --- |
|
|
44
44
|
| **대시보드 웹 서버** | `npm start` | `http://127.0.0.1:4319` — 내 데이터를 눈으로 확인·관리. `127.0.0.1`에만 바인딩. |
|
|
45
|
-
| **MCP 서버** | `npm run mcp` | stdio 기반. 보통 AI 클라이언트가 자동 실행. 도구
|
|
45
|
+
| **MCP 서버** | `npm run mcp` | stdio 기반. 보통 AI 클라이언트가 자동 실행. 도구 31개 제공. |
|
|
46
46
|
|
|
47
47
|
---
|
|
48
48
|
|
|
@@ -139,21 +139,23 @@ Codex가 `~/.codex/config.toml`에 MCP 서버를 등록합니다. 연결은 Code
|
|
|
139
139
|
|
|
140
140
|
---
|
|
141
141
|
|
|
142
|
-
## MCP 도구 한눈에 (
|
|
142
|
+
## MCP 도구 한눈에 (31개)
|
|
143
143
|
|
|
144
144
|
| 분류 | 도구 |
|
|
145
145
|
| --- | --- |
|
|
146
|
-
| 온보딩 | `get_onboarding_status`
|
|
147
|
-
| 프로필 | `save_profile` · `get_profile`
|
|
148
|
-
| 이력서 | `add_resume` · `get_resumes` |
|
|
149
|
-
|
|
|
150
|
-
|
|
|
146
|
+
| 온보딩 | `get_onboarding_status` |
|
|
147
|
+
| 프로필 | `save_profile` · `get_profile` |
|
|
148
|
+
| 이력서 | `read_document` · `add_resume` · `get_resumes` · `open_inbox` · `read_inbox` |
|
|
149
|
+
| 경력·프로젝트·스킬 | `add_experience` · `get_experiences` · `add_project` · `get_projects` · `add_skill` · `get_skills` |
|
|
150
|
+
| 자기소개서 | `get_cover_letters` · `save_cover_letter_version` · `export_cover_letter` |
|
|
151
|
+
| 채용공고 | `save_job_posting` · `get_job_posting` · `list_jobs` |
|
|
151
152
|
| 핵심 컨텍스트 | **`get_application_context`** (지원에 필요한 맥락을 한 번에 모아줌) |
|
|
152
153
|
| 핏 분석 | `save_fit_analysis` |
|
|
153
154
|
| 지원 상태 | `update_application_status` |
|
|
154
155
|
| 면접 준비 | `save_interview_prep` |
|
|
155
156
|
| 글쓰기 | `get_writing_style_guide` (AI 티 안 나는 한국어 글쓰기 규칙) |
|
|
156
157
|
| 대시보드/활동 | `open_dashboard` · `open_application` · `list_recent_activity` · `get_workflow_guide` |
|
|
158
|
+
| 업데이트 | `check_for_update` · `update_careermate` |
|
|
157
159
|
|
|
158
160
|
이 도구들 위에 5종의 워크플로우(`onboarding`, `analyze_job`, `write_cover_letter`, `manage_application_status`, `prepare_interview`)가 정의되어 있어, AI가 단계별로 자연스럽게 안내합니다.
|
|
159
161
|
|
|
@@ -170,7 +172,7 @@ CareerMate/
|
|
|
170
172
|
│ ├─ shared/ # 공용 타입·zod 스키마·유틸
|
|
171
173
|
│ ├─ db/ # node:sqlite DB 접근·스키마·마이그레이션
|
|
172
174
|
│ ├─ core/ # 도메인 유스케이스
|
|
173
|
-
│ ├─ mcp-tools/ # MCP 도구
|
|
175
|
+
│ ├─ mcp-tools/ # MCP 도구 31개 정의
|
|
174
176
|
│ ├─ exporters/ # 내보내기(자소서 등)
|
|
175
177
|
│ ├─ parsers/ # 채용공고 파싱
|
|
176
178
|
│ ├─ prompts/ # 프롬프트·안내 문구
|
|
@@ -256,4 +258,4 @@ npm run typecheck
|
|
|
256
258
|
|
|
257
259
|
## 라이선스
|
|
258
260
|
|
|
259
|
-
MIT
|
|
261
|
+
MIT 라이선스 — npm으로 배포되는 패키지 코드에 적용됩니다. (개발용 GitHub 저장소는 현재 비공개입니다.)
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -38,3 +38,18 @@ SOFTWARE.
|
|
|
38
38
|
|
|
39
39
|
> 참고: 원 저장소의 정확한 저작권자 표기(`Copyright (c) <year> <holder>`)를 확인해
|
|
40
40
|
> 위 전문 상단에 그대로 채워 넣는 것을 권장합니다.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 번들에 포함되는 npm 의존성 (문서 텍스트 추출)
|
|
45
|
+
|
|
46
|
+
`read_document` 도구가 PDF·Word·한컴 문서에서 텍스트를 추출하는 데 사용하는 순수 JS 라이브러리들입니다. 배포 시 `dist/` 번들(esbuild)에 코드가 인라인되므로 각 라이선스 고지를 보존합니다. 각 라이선스 전문은 설치된 `node_modules/<패키지>/LICENSE` 에 포함됩니다.
|
|
47
|
+
|
|
48
|
+
| 패키지 | 용도 | 라이선스 | 저작권 |
|
|
49
|
+
| --- | --- | --- | --- |
|
|
50
|
+
| [`mammoth`](https://github.com/mwilliamson/mammoth.js) | `.docx` → 텍스트 | BSD-2-Clause | Copyright (c) Michael Williamson |
|
|
51
|
+
| [`hwp.js`](https://github.com/hahnlee/hwp.js) | `.hwp`(HWP 5.x) → 텍스트 | Apache-2.0 | Copyright Han Lee and other contributors |
|
|
52
|
+
| [`fflate`](https://github.com/101arrowz/fflate) | `.hwpx` 압축 해제 | MIT | Copyright (c) 2023 Arjun Barrett |
|
|
53
|
+
| [`pdfjs-dist`](https://github.com/mozilla/pdf.js) | `.pdf` → 텍스트 | Apache-2.0 | Copyright Mozilla Foundation and contributors |
|
|
54
|
+
|
|
55
|
+
> Apache-2.0(`hwp.js`, `pdfjs-dist`)은 라이선스 사본과 NOTICE 고지 보존을 요구합니다. 위 표와 `node_modules`의 동봉 라이선스로 이를 충족합니다.
|
package/apps/mcp/src/index.ts
CHANGED
|
@@ -6,16 +6,15 @@
|
|
|
6
6
|
* dashboard uses, so anything the AI writes appears in the dashboard instantly,
|
|
7
7
|
* and anything the user edits in the dashboard is visible to the AI.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* All reasoning stays in the user's AI. The only outbound network call is an
|
|
10
|
+
* optional version check (check_for_update / update_careermate) against the
|
|
11
|
+
* public npm registry — no user data is sent; disable with CAREERMATE_NO_UPDATE_CHECK.
|
|
10
12
|
*/
|
|
11
13
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
12
14
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
13
15
|
import { getDb, getDataDir } from '@careermate/db';
|
|
14
16
|
import { TOOLS, toCallToolResult } from '@careermate/mcp-tools';
|
|
15
|
-
|
|
16
|
-
// 번들 빌드(esbuild)에서 package.json 버전으로 치환된다. tsx 개발 실행에서는 정의되지 않아 typeof로 안전 분기.
|
|
17
|
-
declare const __APP_VERSION__: string | undefined;
|
|
18
|
-
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0-dev';
|
|
17
|
+
import { APP_VERSION } from '@careermate/shared';
|
|
19
18
|
|
|
20
19
|
async function main(): Promise<void> {
|
|
21
20
|
// Ensure the DB is created/migrated before serving any tool call.
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Demo shim — makes the CareerMate dashboard run with NO server.
|
|
3
|
+
//
|
|
4
|
+
// The real dashboard is a static SPA that talks to a local Node server over
|
|
5
|
+
// /api/*. Here we intercept window.fetch and serve everything from an in-memory
|
|
6
|
+
// DB seeded from seed.js. Writes mutate that DB (so editing the profile updates
|
|
7
|
+
// the home counts, etc.), but nothing is persisted — a page reload resets to the
|
|
8
|
+
// seed. Response shapes mirror apps/web/src/routes.ts exactly.
|
|
9
|
+
//
|
|
10
|
+
// This module is injected BEFORE app.js (see scripts/build-demo.mjs), so fetch is
|
|
11
|
+
// patched before any page code calls it.
|
|
12
|
+
// =============================================================================
|
|
13
|
+
import { buildSeed } from './seed.js';
|
|
14
|
+
|
|
15
|
+
/* ------------------------------------------------------------- constants */
|
|
16
|
+
const STATUS_LABELS = {
|
|
17
|
+
draft: '작성 중', planned: '지원 예정', applied: '지원 완료', document_passed: '서류 합격',
|
|
18
|
+
interview: '면접 진행', final_passed: '최종 합격', rejected: '불합격', on_hold: '보류',
|
|
19
|
+
};
|
|
20
|
+
const BOARD_ORDER = ['draft', 'planned', 'applied', 'document_passed', 'interview', 'final_passed', 'on_hold', 'rejected'];
|
|
21
|
+
const INTERVIEW_UNLOCK = ['document_passed', 'interview', 'final_passed'];
|
|
22
|
+
const ACTIVE = ['draft', 'planned', 'applied', 'document_passed', 'interview'];
|
|
23
|
+
const DOC_KIND_LABELS = { resume: '이력서', career_description: '경력기술서', portfolio: '포트폴리오', other: '기타 문서' };
|
|
24
|
+
|
|
25
|
+
/* --------------------------------------------------------------- the DB */
|
|
26
|
+
let db = buildSeed();
|
|
27
|
+
let seq = 1000;
|
|
28
|
+
const nid = (p) => `${p}${++seq}`;
|
|
29
|
+
const nowIso = () => new Date().toISOString();
|
|
30
|
+
const clone = (v) => JSON.parse(JSON.stringify(v));
|
|
31
|
+
|
|
32
|
+
function logActivity(type, summary, entity_type = null, entity_id = null) {
|
|
33
|
+
db.activities.unshift({ id: nid('ac'), type, entity_type, entity_id, summary, created_at: nowIso() });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* ------------------------------------------------------- derived helpers */
|
|
37
|
+
const appByJob = (jobId) => db.applications.find((a) => a.job_id === jobId) || null;
|
|
38
|
+
const fitByJob = (jobId) => db.fits.find((f) => f.job_id === jobId) || null;
|
|
39
|
+
const ivByJob = (jobId) => db.interviews.find((i) => i.job_id === jobId) || null;
|
|
40
|
+
const clByJob = (jobId) => db.coverLetters.filter((c) => c.job_id === jobId);
|
|
41
|
+
|
|
42
|
+
function jobWithMeta(job) {
|
|
43
|
+
const app = appByJob(job.id);
|
|
44
|
+
const fit = fitByJob(job.id);
|
|
45
|
+
const status = app?.status ?? 'draft';
|
|
46
|
+
return { ...job, status, status_label: STATUS_LABELS[status], fit_score: fit?.score ?? null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function onboarding() {
|
|
50
|
+
const p = db.profile;
|
|
51
|
+
const has_profile = !!(p && p.name);
|
|
52
|
+
const has_resume = db.documents.some((d) => d.kind === 'resume');
|
|
53
|
+
const has_cover_letter = db.coverLetters.length > 0;
|
|
54
|
+
const has_experience = db.experiences.length > 0;
|
|
55
|
+
const has_skills = db.skills.length > 0;
|
|
56
|
+
const has_job = db.jobs.length > 0;
|
|
57
|
+
const checks = p ? [
|
|
58
|
+
!!p.name, !!(p.headline || p.summary), !!p.location, p.desired_roles.length > 0,
|
|
59
|
+
!!p.desired_conditions, !!p.preferred_tone, p.emphasis_points.length > 0, p.links.length > 0,
|
|
60
|
+
db.experiences.length > 0, db.skills.length > 0,
|
|
61
|
+
] : [];
|
|
62
|
+
const completeness = p ? Math.round((checks.filter(Boolean).length / checks.length) * 100) : 0;
|
|
63
|
+
const completed = has_profile && (has_resume || has_experience);
|
|
64
|
+
const next_steps = [];
|
|
65
|
+
if (!has_profile) next_steps.push('기본 프로필을 입력하세요 (이름, 한 줄 소개, 희망 직무).');
|
|
66
|
+
if (!has_resume) next_steps.push('이력서나 경력기술서를 추가하세요. 파일 내용을 붙여넣어도 됩니다.');
|
|
67
|
+
if (!has_experience) next_steps.push('주요 경력을 1개 이상 추가하면 적합도 분석 품질이 올라갑니다.');
|
|
68
|
+
if (!has_skills) next_steps.push('보유 기술스택을 정리해 두면 공고 매칭이 정확해집니다.');
|
|
69
|
+
if (has_profile && !has_cover_letter) next_steps.push('기존 자기소개서가 있다면 추가해 두세요. AI가 문체를 학습합니다.');
|
|
70
|
+
if (completed && !has_job) next_steps.push('관심 있는 채용공고 URL이나 내용을 AI에게 전달해 적합도 분석을 받아보세요.');
|
|
71
|
+
if (next_steps.length === 0) next_steps.push('준비가 잘 되어 있어요. 새 공고를 분석하거나 자기소개서를 작성해 보세요.');
|
|
72
|
+
return { completed, has_profile, has_resume, has_cover_letter, has_experience, has_skills, has_job, profile_completeness: completeness, next_steps };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function homeSummary() {
|
|
76
|
+
const apps = db.applications;
|
|
77
|
+
const status_breakdown = Object.keys(STATUS_LABELS).map((status) => ({
|
|
78
|
+
status, label: STATUS_LABELS[status], count: apps.filter((a) => a.status === status).length,
|
|
79
|
+
}));
|
|
80
|
+
const active = apps.filter((a) => ACTIVE.includes(a.status));
|
|
81
|
+
const interview_todo = apps
|
|
82
|
+
.filter((a) => INTERVIEW_UNLOCK.includes(a.status) && !ivByJob(a.job_id))
|
|
83
|
+
.map((a) => { const job = db.jobs.find((j) => j.id === a.job_id); return job ? { job, status_label: STATUS_LABELS[a.status] } : null; })
|
|
84
|
+
.filter(Boolean);
|
|
85
|
+
return {
|
|
86
|
+
onboarding: onboarding(),
|
|
87
|
+
counts: {
|
|
88
|
+
jobs: db.jobs.length,
|
|
89
|
+
active_applications: active.length,
|
|
90
|
+
cover_letters: db.coverLetters.length,
|
|
91
|
+
interview_pending: interview_todo.length,
|
|
92
|
+
},
|
|
93
|
+
status_breakdown,
|
|
94
|
+
recent_jobs: db.jobs.slice(0, 6).map(jobWithMeta),
|
|
95
|
+
in_progress: active.slice(0, 6).map((application) => ({
|
|
96
|
+
application, job: db.jobs.find((j) => j.id === application.job_id) || null, status_label: STATUS_LABELS[application.status],
|
|
97
|
+
})),
|
|
98
|
+
recent_cover_letters: db.coverLetters.slice(0, 5),
|
|
99
|
+
interview_todo,
|
|
100
|
+
recent_activity: db.activities.slice(0, 10),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function jobDetail(jobId) {
|
|
105
|
+
const job = db.jobs.find((j) => j.id === jobId);
|
|
106
|
+
if (!job) return { __status: 404, error: '공고를 찾을 수 없습니다.' };
|
|
107
|
+
const related = db.jobs
|
|
108
|
+
.filter((j) => j.id !== jobId && (j.company === job.company || j.position === job.position))
|
|
109
|
+
.map(jobWithMeta);
|
|
110
|
+
return {
|
|
111
|
+
job: {
|
|
112
|
+
...jobWithMeta(job),
|
|
113
|
+
application: appByJob(jobId),
|
|
114
|
+
fit: fitByJob(jobId),
|
|
115
|
+
cover_letters: clByJob(jobId),
|
|
116
|
+
interview: ivByJob(jobId),
|
|
117
|
+
related,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function settingsInfo() {
|
|
123
|
+
return {
|
|
124
|
+
name: 'CareerMate', version: 'demo', port: 0, url: '',
|
|
125
|
+
data_dir: '데모 모드 — 브라우저 메모리에만 존재',
|
|
126
|
+
db_path: '저장되지 않습니다 (새로고침 시 초기화)',
|
|
127
|
+
exports_dir: '데모', backups_dir: '데모', node_version: '데모',
|
|
128
|
+
counts: {
|
|
129
|
+
profile: db.profile ? 1 : 0, experiences: db.experiences.length, projects: db.projects.length,
|
|
130
|
+
skills: db.skills.length, documents: db.documents.length, cover_letters: db.coverLetters.length,
|
|
131
|
+
jobs: db.jobs.length, applications: db.applications.length, interview_preps: db.interviews.length,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* --------------------------------------------------- normalizers (input) */
|
|
137
|
+
// Mirror the *Input → *Record defaulting the server's repos perform.
|
|
138
|
+
function normProfile(input, base) {
|
|
139
|
+
const b = base || {};
|
|
140
|
+
return {
|
|
141
|
+
id: 'p1', created_at: b.created_at || nowIso(), updated_at: nowIso(),
|
|
142
|
+
name: input.name ?? b.name ?? null, email: input.email ?? b.email ?? null,
|
|
143
|
+
phone: input.phone ?? b.phone ?? null, location: input.location ?? b.location ?? null,
|
|
144
|
+
headline: input.headline ?? b.headline ?? null, summary: input.summary ?? b.summary ?? null,
|
|
145
|
+
desired_roles: input.desired_roles ?? b.desired_roles ?? [],
|
|
146
|
+
desired_conditions: input.desired_conditions ?? b.desired_conditions ?? null,
|
|
147
|
+
preferred_tone: input.preferred_tone ?? b.preferred_tone ?? null,
|
|
148
|
+
emphasis_points: input.emphasis_points ?? b.emphasis_points ?? [],
|
|
149
|
+
links: input.links ?? b.links ?? [],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
153
|
+
|
|
154
|
+
/* -------------------------------------------------------------- routes */
|
|
155
|
+
// Each: [method, RegExp on pathname, handler(params, body, query) -> data | {__status,error}]
|
|
156
|
+
const ROUTES = [
|
|
157
|
+
['GET', /^\/api\/health$/, () => ({ ok: true, ...settingsInfo() })],
|
|
158
|
+
['GET', /^\/api\/onboarding$/, () => onboarding()],
|
|
159
|
+
['GET', /^\/api\/summary$/, () => homeSummary()],
|
|
160
|
+
['GET', /^\/api\/activity$/, () => ({ activities: db.activities.slice(0, 30) })],
|
|
161
|
+
['GET', /^\/api\/meta$/, () => ({
|
|
162
|
+
statuses: BOARD_ORDER.map((s) => ({ value: s, label: STATUS_LABELS[s] })),
|
|
163
|
+
document_kinds: Object.entries(DOC_KIND_LABELS).map(([value, label]) => ({ value, label })),
|
|
164
|
+
})],
|
|
165
|
+
|
|
166
|
+
/* profile */
|
|
167
|
+
['GET', /^\/api\/profile$/, () => ({ profile: db.profile })],
|
|
168
|
+
['PUT', /^\/api\/profile$/, (_p, body) => {
|
|
169
|
+
db.profile = normProfile(body, db.profile);
|
|
170
|
+
logActivity('profile_updated', '프로필을 업데이트했습니다.', 'profile', 'p1');
|
|
171
|
+
return { profile: db.profile };
|
|
172
|
+
}],
|
|
173
|
+
|
|
174
|
+
/* experiences / projects / skills share a CRUD shape */
|
|
175
|
+
...crud('experiences', 'experience', ['company', 'role', 'employment_type', 'start_date', 'end_date', 'is_current', 'description', 'achievements', 'tech', 'order_index']),
|
|
176
|
+
...crud('projects', 'project', ['name', 'role', 'description', 'highlights', 'tech', 'url', 'start_date', 'end_date', 'order_index']),
|
|
177
|
+
...crud('skills', 'skill', ['name', 'category', 'level', 'years', 'order_index']),
|
|
178
|
+
|
|
179
|
+
/* documents */
|
|
180
|
+
['GET', /^\/api\/documents$/, (_p, _b, q) => {
|
|
181
|
+
const kind = q.get('kind');
|
|
182
|
+
return { documents: kind ? db.documents.filter((d) => d.kind === kind) : db.documents };
|
|
183
|
+
}],
|
|
184
|
+
['POST', /^\/api\/documents$/, (_p, body) => {
|
|
185
|
+
const doc = {
|
|
186
|
+
id: nid('d'), created_at: nowIso(), updated_at: nowIso(),
|
|
187
|
+
kind: body.kind || 'other', title: body.title || '제목 없음', content: body.content || '',
|
|
188
|
+
source: body.source || 'manual', is_primary: !!body.is_primary, tags: arr(body.tags),
|
|
189
|
+
};
|
|
190
|
+
if (doc.is_primary) db.documents.forEach((d) => { if (d.kind === doc.kind) d.is_primary = false; });
|
|
191
|
+
db.documents.unshift(doc);
|
|
192
|
+
logActivity(doc.kind === 'resume' ? 'resume_added' : 'profile_updated', `${doc.title}을(를) 추가했습니다.`, 'document', doc.id);
|
|
193
|
+
return { document: doc };
|
|
194
|
+
}],
|
|
195
|
+
['GET', /^\/api\/documents\/([^/]+)$/, (p) => {
|
|
196
|
+
const doc = db.documents.find((d) => d.id === p[0]);
|
|
197
|
+
return doc ? { document: doc } : { __status: 404, error: '문서를 찾을 수 없습니다.' };
|
|
198
|
+
}],
|
|
199
|
+
['PUT', /^\/api\/documents\/([^/]+)$/, (p, body) => {
|
|
200
|
+
const doc = db.documents.find((d) => d.id === p[0]);
|
|
201
|
+
if (!doc) return { __status: 404, error: '문서를 찾을 수 없습니다.' };
|
|
202
|
+
Object.assign(doc, pick(body, ['kind', 'title', 'content', 'source', 'is_primary', 'tags']), { updated_at: nowIso() });
|
|
203
|
+
return { document: doc };
|
|
204
|
+
}],
|
|
205
|
+
['DELETE', /^\/api\/documents\/([^/]+)$/, (p) => remove(db.documents, p[0])],
|
|
206
|
+
|
|
207
|
+
/* cover letters */
|
|
208
|
+
['GET', /^\/api\/cover-letters$/, () => ({ cover_letters: db.coverLetters })],
|
|
209
|
+
['POST', /^\/api\/cover-letters$/, (_p, body) => {
|
|
210
|
+
const id = nid('c'); const t = nowIso();
|
|
211
|
+
const cl = {
|
|
212
|
+
id, created_at: t, updated_at: t, title: body.title || '제목 없음', job_id: body.job_id ?? null,
|
|
213
|
+
is_primary: !!body.is_primary, current_version_id: null, version_count: 0, current_content: null, versions: [],
|
|
214
|
+
};
|
|
215
|
+
if (body.content) addVersion(cl, body.content, body.note, body.source || 'manual');
|
|
216
|
+
if (cl.is_primary) db.coverLetters.forEach((c) => { c.is_primary = false; });
|
|
217
|
+
cl.is_primary = !!body.is_primary;
|
|
218
|
+
db.coverLetters.unshift(cl);
|
|
219
|
+
logActivity('cover_letter_added', `${cl.title} 자기소개서를 추가했습니다.`, 'cover_letter', id);
|
|
220
|
+
return { cover_letter: cl };
|
|
221
|
+
}],
|
|
222
|
+
['GET', /^\/api\/cover-letters\/([^/]+)$/, (p) => {
|
|
223
|
+
const cl = db.coverLetters.find((c) => c.id === p[0]);
|
|
224
|
+
return cl ? { cover_letter: cl } : { __status: 404, error: '자기소개서를 찾을 수 없습니다.' };
|
|
225
|
+
}],
|
|
226
|
+
['POST', /^\/api\/cover-letters\/([^/]+)\/versions$/, (p, body) => {
|
|
227
|
+
const cl = db.coverLetters.find((c) => c.id === p[0]);
|
|
228
|
+
if (!cl) return { __status: 404, error: '자기소개서를 찾을 수 없습니다.' };
|
|
229
|
+
const version = addVersion(cl, body.content, body.note, body.source || 'edit');
|
|
230
|
+
logActivity('cover_letter_version_saved', `${cl.title} v${version.version_no}을(를) 저장했습니다.`, 'cover_letter', cl.id);
|
|
231
|
+
return { cover_letter: cl, version };
|
|
232
|
+
}],
|
|
233
|
+
['PUT', /^\/api\/cover-letters\/([^/]+)\/current-version$/, (p, body) => {
|
|
234
|
+
const cl = db.coverLetters.find((c) => c.id === p[0]);
|
|
235
|
+
if (!cl) return { __status: 404, error: '자기소개서를 찾을 수 없습니다.' };
|
|
236
|
+
const v = (cl.versions || []).find((x) => x.id === body.version_id);
|
|
237
|
+
if (v) { cl.current_version_id = v.id; cl.current_content = v.content; cl.updated_at = nowIso(); }
|
|
238
|
+
return { cover_letter: cl };
|
|
239
|
+
}],
|
|
240
|
+
['PUT', /^\/api\/cover-letters\/([^/]+)\/primary$/, (p) => {
|
|
241
|
+
const cl = db.coverLetters.find((c) => c.id === p[0]);
|
|
242
|
+
if (!cl) return { __status: 404, error: '자기소개서를 찾을 수 없습니다.' };
|
|
243
|
+
db.coverLetters.forEach((c) => { c.is_primary = c.id === cl.id; });
|
|
244
|
+
cl.updated_at = nowIso();
|
|
245
|
+
return { cover_letter: cl };
|
|
246
|
+
}],
|
|
247
|
+
['DELETE', /^\/api\/cover-letters\/([^/]+)$/, (p) => remove(db.coverLetters, p[0])],
|
|
248
|
+
|
|
249
|
+
/* jobs */
|
|
250
|
+
['GET', /^\/api\/jobs$/, () => ({ jobs: db.jobs.map(jobWithMeta) })],
|
|
251
|
+
['POST', /^\/api\/jobs$/, (_p, body) => {
|
|
252
|
+
const job = normJob(body);
|
|
253
|
+
db.jobs.unshift(job);
|
|
254
|
+
const application = { id: nid('a'), created_at: nowIso(), updated_at: nowIso(), job_id: job.id, status: 'draft', resume_id: null, cover_letter_id: null, applied_at: null, notes: null };
|
|
255
|
+
db.applications.unshift(application);
|
|
256
|
+
logActivity('job_saved', `${job.company} 공고를 저장했습니다.`, 'job', job.id);
|
|
257
|
+
return { job: jobWithMeta(job), application };
|
|
258
|
+
}],
|
|
259
|
+
['GET', /^\/api\/jobs\/([^/]+)\/interview$/, (p) => ({ interview: ivByJob(p[0]) })],
|
|
260
|
+
['PUT', /^\/api\/jobs\/([^/]+)\/interview$/, (p, body) => {
|
|
261
|
+
let iv = ivByJob(p[0]);
|
|
262
|
+
if (!iv) { iv = { id: nid('iv'), created_at: nowIso(), updated_at: nowIso(), job_id: p[0], questions: [], star_guides: [], self_introduction: null, notes: null }; db.interviews.unshift(iv); }
|
|
263
|
+
Object.assign(iv, { questions: arr(body.questions), star_guides: arr(body.star_guides), self_introduction: body.self_introduction ?? iv.self_introduction, notes: body.notes ?? iv.notes, updated_at: nowIso() });
|
|
264
|
+
logActivity('interview_prep_saved', '면접 준비 자료를 저장했습니다.', 'interview_prep', iv.id);
|
|
265
|
+
return { interview: iv };
|
|
266
|
+
}],
|
|
267
|
+
['GET', /^\/api\/jobs\/([^/]+)$/, (p) => jobDetail(p[0])],
|
|
268
|
+
['PUT', /^\/api\/jobs\/([^/]+)$/, (p, body) => {
|
|
269
|
+
const job = db.jobs.find((j) => j.id === p[0]);
|
|
270
|
+
if (!job) return { __status: 404, error: '공고를 찾을 수 없습니다.' };
|
|
271
|
+
Object.assign(job, pick(body, ['company', 'position', 'url', 'location', 'employment_type', 'description', 'requirements', 'keywords', 'deadline', 'source']), { updated_at: nowIso() });
|
|
272
|
+
return { job: jobWithMeta(job) };
|
|
273
|
+
}],
|
|
274
|
+
['DELETE', /^\/api\/jobs\/([^/]+)$/, (p) => {
|
|
275
|
+
db.applications = db.applications.filter((a) => a.job_id !== p[0]);
|
|
276
|
+
db.fits = db.fits.filter((f) => f.job_id !== p[0]);
|
|
277
|
+
db.interviews = db.interviews.filter((i) => i.job_id !== p[0]);
|
|
278
|
+
return remove(db.jobs, p[0]);
|
|
279
|
+
}],
|
|
280
|
+
|
|
281
|
+
/* interview page */
|
|
282
|
+
['GET', /^\/api\/interview$/, () => {
|
|
283
|
+
const preps = db.interviews.map((pp) => ({ ...pp, job: db.jobs.find((j) => j.id === pp.job_id) }));
|
|
284
|
+
const eligible = db.applications
|
|
285
|
+
.filter((a) => INTERVIEW_UNLOCK.includes(a.status))
|
|
286
|
+
.map((a) => ({ job: db.jobs.find((j) => j.id === a.job_id), status: a.status, status_label: STATUS_LABELS[a.status], has_prep: !!ivByJob(a.job_id) }))
|
|
287
|
+
.filter((x) => x.job);
|
|
288
|
+
return { preps, eligible };
|
|
289
|
+
}],
|
|
290
|
+
|
|
291
|
+
/* applications */
|
|
292
|
+
['GET', /^\/api\/applications$/, () => ({
|
|
293
|
+
applications: db.applications.map((a) => ({ ...a, status_label: STATUS_LABELS[a.status], job: db.jobs.find((j) => j.id === a.job_id), fit_score: fitByJob(a.job_id)?.score ?? null })),
|
|
294
|
+
board_order: BOARD_ORDER,
|
|
295
|
+
})],
|
|
296
|
+
['PUT', /^\/api\/applications\/([^/]+)\/status$/, (p, body) => {
|
|
297
|
+
const job = db.jobs.find((j) => j.id === p[0]);
|
|
298
|
+
if (!job) return { __status: 404, error: '공고를 찾을 수 없습니다.' };
|
|
299
|
+
let app = appByJob(p[0]);
|
|
300
|
+
if (!app) { app = { id: nid('a'), created_at: nowIso(), updated_at: nowIso(), job_id: p[0], status: 'draft', resume_id: null, cover_letter_id: null, applied_at: null, notes: null }; db.applications.unshift(app); }
|
|
301
|
+
app.status = body.status; app.updated_at = nowIso();
|
|
302
|
+
if (body.note) app.notes = body.note;
|
|
303
|
+
logActivity('application_status_changed', `${job.company} · ${job.position} 상태를 '${STATUS_LABELS[body.status]}'(으)로 변경했습니다.`, 'application', app.id);
|
|
304
|
+
const interview_unlocked = INTERVIEW_UNLOCK.includes(body.status);
|
|
305
|
+
return { application: app, job, interview_unlocked, hint: interview_unlocked && !ivByJob(p[0]) ? '서류 단계를 통과했어요. 면접 질문과 자기소개를 준비해 보세요.' : null };
|
|
306
|
+
}],
|
|
307
|
+
['PUT', /^\/api\/applications\/([^/]+)$/, (p, body) => {
|
|
308
|
+
let app = appByJob(p[0]);
|
|
309
|
+
if (!app) { app = { id: nid('a'), created_at: nowIso(), updated_at: nowIso(), job_id: p[0], status: 'draft', resume_id: null, cover_letter_id: null, applied_at: null, notes: null }; db.applications.unshift(app); }
|
|
310
|
+
Object.assign(app, pick(body, ['status', 'resume_id', 'cover_letter_id', 'applied_at', 'notes']), { updated_at: nowIso() });
|
|
311
|
+
return { application: app };
|
|
312
|
+
}],
|
|
313
|
+
|
|
314
|
+
/* settings */
|
|
315
|
+
['GET', /^\/api\/settings$/, () => ({ info: settingsInfo(), backups: db.backups || [] })],
|
|
316
|
+
['POST', /^\/api\/settings\/backup$/, () => {
|
|
317
|
+
db.backups = db.backups || [];
|
|
318
|
+
const b = { filename: `careermate-${nowIso().replace(/[:.]/g, '-')}.sqlite`, path: '(데모)', created_at: nowIso(), size: 48 * 1024 };
|
|
319
|
+
db.backups.unshift(b);
|
|
320
|
+
return b;
|
|
321
|
+
}],
|
|
322
|
+
['POST', /^\/api\/settings\/reset$/, (_p, body) => {
|
|
323
|
+
if (body.confirm !== 'DELETE') return { __status: 400, error: 'confirm 값이 올바르지 않습니다.' };
|
|
324
|
+
db = buildSeed(); // demo "reset" = back to the seed
|
|
325
|
+
return { ok: true };
|
|
326
|
+
}],
|
|
327
|
+
|
|
328
|
+
/* parse helper (deterministic, no AI) */
|
|
329
|
+
['POST', /^\/api\/parse\/job$/, (_p, body) => cleanPosting(body.raw || '')],
|
|
330
|
+
];
|
|
331
|
+
|
|
332
|
+
/* -------------------------------------------------- CRUD route factory */
|
|
333
|
+
function crud(coll, key, fields) {
|
|
334
|
+
const base = `/api/${coll}`;
|
|
335
|
+
const re = new RegExp(`^/api/${coll}/([^/]+)$`);
|
|
336
|
+
return [
|
|
337
|
+
['GET', new RegExp(`^/api/${coll}$`), () => ({ [coll]: db[coll] })],
|
|
338
|
+
['POST', new RegExp(`^/api/${coll}$`), (_p, body) => {
|
|
339
|
+
const rec = { id: nid(key[0]), created_at: nowIso(), updated_at: nowIso(), ...defaults(fields), ...pick(body, fields) };
|
|
340
|
+
db[coll].push(rec);
|
|
341
|
+
return { [key]: rec };
|
|
342
|
+
}],
|
|
343
|
+
['PUT', re, (p, body) => {
|
|
344
|
+
const rec = db[coll].find((x) => x.id === p[0]);
|
|
345
|
+
if (!rec) return { __status: 404, error: '항목을 찾을 수 없습니다.' };
|
|
346
|
+
Object.assign(rec, pick(body, fields), { updated_at: nowIso() });
|
|
347
|
+
return { [key]: rec };
|
|
348
|
+
}],
|
|
349
|
+
['DELETE', re, (p) => remove(db[coll], p[0])],
|
|
350
|
+
];
|
|
351
|
+
}
|
|
352
|
+
function defaults(fields) {
|
|
353
|
+
const out = {};
|
|
354
|
+
for (const f of fields) {
|
|
355
|
+
if (['achievements', 'tech', 'highlights'].includes(f)) out[f] = [];
|
|
356
|
+
else if (f === 'is_current') out[f] = false;
|
|
357
|
+
else if (f === 'order_index') out[f] = 0;
|
|
358
|
+
else out[f] = null;
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/* ------------------------------------------------------------- helpers */
|
|
364
|
+
function pick(obj, fields) {
|
|
365
|
+
const out = {};
|
|
366
|
+
for (const f of fields) if (obj[f] !== undefined) out[f] = obj[f];
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
function remove(coll, id) {
|
|
370
|
+
const i = coll.findIndex((x) => x.id === id);
|
|
371
|
+
if (i >= 0) coll.splice(i, 1);
|
|
372
|
+
return { ok: i >= 0 };
|
|
373
|
+
}
|
|
374
|
+
function addVersion(cl, content, note, source) {
|
|
375
|
+
cl.versions = cl.versions || [];
|
|
376
|
+
const v = { id: nid('clv'), cover_letter_id: cl.id, version_no: cl.versions.length + 1, content, note: note ?? null, source: source || 'manual', created_at: nowIso() };
|
|
377
|
+
cl.versions.push(v);
|
|
378
|
+
cl.current_version_id = v.id; cl.current_content = content; cl.version_count = cl.versions.length; cl.updated_at = nowIso();
|
|
379
|
+
return v;
|
|
380
|
+
}
|
|
381
|
+
function normJob(body) {
|
|
382
|
+
return {
|
|
383
|
+
id: nid('j'), created_at: nowIso(), updated_at: nowIso(),
|
|
384
|
+
company: body.company || '', position: body.position || '', url: body.url ?? null,
|
|
385
|
+
location: body.location ?? null, employment_type: body.employment_type ?? null,
|
|
386
|
+
description: body.description ?? null, requirements: arr(body.requirements), keywords: arr(body.keywords),
|
|
387
|
+
deadline: body.deadline ?? null, source: body.source ?? null,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
const TECH = ['Java', 'Spring', 'Kotlin', 'Node.js', 'TypeScript', 'Python', 'Go', 'Kafka', 'MySQL', 'PostgreSQL', 'Redis', 'Kubernetes', 'AWS', 'Docker', 'React', 'MSA'];
|
|
391
|
+
function cleanPosting(raw) {
|
|
392
|
+
const text = String(raw).replace(/\r\n?/g, '\n').split('\n').map((l) => l.replace(/[ \t]+/g, ' ').trim()).filter(Boolean).join('\n');
|
|
393
|
+
const keywords = TECH.filter((t) => new RegExp(`\\b${t.replace(/[.+]/g, '\\$&')}\\b`, 'i').test(text));
|
|
394
|
+
const out = { text, keywords };
|
|
395
|
+
const m = text.match(/(?:마감|접수\s*마감)[^\d]*(\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2})/);
|
|
396
|
+
if (m) out.deadline = m[1].replace(/[.\/]/g, '-');
|
|
397
|
+
return out;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* ----------------------------------------------------- fetch interception */
|
|
401
|
+
const realFetch = window.fetch.bind(window);
|
|
402
|
+
function jsonResponse(data) {
|
|
403
|
+
const status = data && data.__status ? data.__status : 200;
|
|
404
|
+
const payload = data && data.__status ? { error: data.error } : data;
|
|
405
|
+
return new Response(JSON.stringify(payload ?? {}), { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
window.fetch = async function (input, init) {
|
|
409
|
+
const url = typeof input === 'string' ? input : input.url;
|
|
410
|
+
let pathname;
|
|
411
|
+
try { pathname = new URL(url, location.href).pathname; } catch { return realFetch(input, init); }
|
|
412
|
+
if (!pathname.startsWith('/api/')) return realFetch(input, init);
|
|
413
|
+
|
|
414
|
+
const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
|
415
|
+
const query = new URL(url, location.href).searchParams;
|
|
416
|
+
let body = {};
|
|
417
|
+
const rawBody = init?.body ?? (input instanceof Request ? null : null);
|
|
418
|
+
if (rawBody) { try { body = JSON.parse(rawBody); } catch { body = {}; } }
|
|
419
|
+
|
|
420
|
+
for (const [m, re, handler] of ROUTES) {
|
|
421
|
+
if (m !== method) continue;
|
|
422
|
+
const match = re.exec(pathname);
|
|
423
|
+
if (!match) continue;
|
|
424
|
+
try {
|
|
425
|
+
const data = handler(match.slice(1), body, query);
|
|
426
|
+
return jsonResponse(clone(data));
|
|
427
|
+
} catch (e) {
|
|
428
|
+
return jsonResponse({ __status: 500, error: e?.message || '데모 처리 중 오류가 발생했습니다.' });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return jsonResponse({ __status: 404, error: '요청한 항목을 찾을 수 없어요.' });
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
/* ------------------------------------------ export-download interception */
|
|
435
|
+
// downloadUrl() builds an <a download href="/api/export/..."> and clicks it.
|
|
436
|
+
// That's a navigation, not fetch — so we catch the click and build the file
|
|
437
|
+
// from the in-memory DB instead.
|
|
438
|
+
function buildExport(pathname, query) {
|
|
439
|
+
const fmt = (query.get('format') || 'md').toLowerCase();
|
|
440
|
+
let m;
|
|
441
|
+
if ((m = pathname.match(/^\/api\/export\/cover-letter\/([^/]+)$/))) {
|
|
442
|
+
const cl = db.coverLetters.find((c) => c.id === m[1]);
|
|
443
|
+
return cl ? { name: `${cl.title}.${fmt === 'html' ? 'html' : 'md'}`, content: wrap(fmt, cl.title, cl.current_content || '') } : null;
|
|
444
|
+
}
|
|
445
|
+
if ((m = pathname.match(/^\/api\/export\/document\/([^/]+)$/))) {
|
|
446
|
+
const d = db.documents.find((x) => x.id === m[1]);
|
|
447
|
+
return d ? { name: `${d.title}.md`, content: d.content || '' } : null;
|
|
448
|
+
}
|
|
449
|
+
if ((m = pathname.match(/^\/api\/export\/interview\/([^/]+)$/))) {
|
|
450
|
+
const iv = ivByJob(m[1]); const job = db.jobs.find((j) => j.id === m[1]);
|
|
451
|
+
if (!iv) return null;
|
|
452
|
+
const lines = [`# 면접 준비 — ${job ? job.company : ''}`, '', iv.self_introduction || '', '', ...(iv.questions || []).map((q) => `- ${q.question}`)];
|
|
453
|
+
return { name: '면접준비.md', content: wrap(fmt, '면접 준비', lines.join('\n')) };
|
|
454
|
+
}
|
|
455
|
+
if (pathname === '/api/settings/export-all') {
|
|
456
|
+
return { name: 'careermate-demo-export.json', content: JSON.stringify({ exported_at: nowIso(), demo: true, data: db }, null, 2) };
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
function wrap(fmt, title, body) {
|
|
461
|
+
if (fmt !== 'html') return body;
|
|
462
|
+
return `<!doctype html><meta charset="utf-8"><title>${title}</title><pre>${body.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]))}</pre>`;
|
|
463
|
+
}
|
|
464
|
+
document.addEventListener('click', (e) => {
|
|
465
|
+
const a = e.target.closest && e.target.closest('a[download]');
|
|
466
|
+
if (!a) return;
|
|
467
|
+
let pathname, query;
|
|
468
|
+
try { const u = new URL(a.getAttribute('href'), location.href); pathname = u.pathname; query = u.searchParams; } catch { return; }
|
|
469
|
+
if (!pathname.startsWith('/api/')) return;
|
|
470
|
+
e.preventDefault();
|
|
471
|
+
const file = buildExport(pathname, query);
|
|
472
|
+
if (!file) return;
|
|
473
|
+
const blob = new Blob([file.content], { type: 'text/plain;charset=utf-8' });
|
|
474
|
+
const href = URL.createObjectURL(blob);
|
|
475
|
+
const link = document.createElement('a');
|
|
476
|
+
link.href = href; link.download = file.name;
|
|
477
|
+
document.body.append(link); link.click(); link.remove();
|
|
478
|
+
setTimeout(() => URL.revokeObjectURL(href), 1000);
|
|
479
|
+
}, true);
|
|
480
|
+
|
|
481
|
+
/* ----------------------------------------------------- theme (synced) */
|
|
482
|
+
// Both the marketing site and this demo share localStorage key 'careermate-theme'
|
|
483
|
+
// and the <html data-theme> attribute (same origin), so a choice on either side
|
|
484
|
+
// carries over. The pre-paint resolver is injected into <head> by build-demo.mjs;
|
|
485
|
+
// here we just wire the toggle button.
|
|
486
|
+
function effectiveTheme() {
|
|
487
|
+
const t = document.documentElement.getAttribute('data-theme');
|
|
488
|
+
if (t === 'light' || t === 'dark') return t;
|
|
489
|
+
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
490
|
+
}
|
|
491
|
+
const SUN = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
|
|
492
|
+
const MOON = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"/></svg>';
|
|
493
|
+
function paintToggle(btn) {
|
|
494
|
+
// Show the icon of the mode you'd switch TO.
|
|
495
|
+
btn.innerHTML = effectiveTheme() === 'dark' ? SUN : MOON;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/* ------------------------------------------------------ demo top bar */
|
|
499
|
+
// A slim top bar that frames the page as a no-save preview and links back to the
|
|
500
|
+
// marketing site. Kept intentionally light so it doesn't compete with the
|
|
501
|
+
// dashboard's own sidebar/topbar navigation.
|
|
502
|
+
function installChrome() {
|
|
503
|
+
if (document.getElementById('demo-bar')) return;
|
|
504
|
+
const bar = document.createElement('div');
|
|
505
|
+
bar.id = 'demo-bar';
|
|
506
|
+
bar.innerHTML =
|
|
507
|
+
'<a class="demo-bar__brand" href="/" title="CareerMate 소개로 돌아가기">' +
|
|
508
|
+
'<span class="demo-bar__logo" aria-hidden="true">C</span>' +
|
|
509
|
+
'<span class="demo-bar__name">CareerMate</span>' +
|
|
510
|
+
'<span class="demo-bar__badge">데모</span>' +
|
|
511
|
+
'</a>' +
|
|
512
|
+
'<span class="demo-bar__text">샘플 데이터 미리보기 · 입력·수정은 저장되지 않아요</span>' +
|
|
513
|
+
'<span class="demo-bar__spacer"></span>' +
|
|
514
|
+
'<button class="demo-bar__icon" id="demo-theme" type="button" aria-label="테마 전환" title="테마 전환"></button>' +
|
|
515
|
+
'<a class="demo-bar__link" href="/#install">설치하기</a>' +
|
|
516
|
+
'<a class="demo-bar__link demo-bar__link--primary" href="/">소개로 ←</a>';
|
|
517
|
+
document.body.prepend(bar);
|
|
518
|
+
document.body.classList.add('has-demo-bar');
|
|
519
|
+
|
|
520
|
+
const toggle = bar.querySelector('#demo-theme');
|
|
521
|
+
paintToggle(toggle);
|
|
522
|
+
toggle.addEventListener('click', () => {
|
|
523
|
+
const next = effectiveTheme() === 'dark' ? 'light' : 'dark';
|
|
524
|
+
document.documentElement.setAttribute('data-theme', next);
|
|
525
|
+
try { localStorage.setItem('careermate-theme', next); localStorage.removeItem('cf-theme'); } catch { /* ignore */ }
|
|
526
|
+
paintToggle(toggle);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// Reframe the sidebar footer (it normally says "로컬에서 실행 중").
|
|
530
|
+
const foot = document.getElementById('foot-status');
|
|
531
|
+
if (foot) foot.textContent = '데모 모드 · 저장 안 됨';
|
|
532
|
+
const footPath = document.getElementById('foot-path');
|
|
533
|
+
if (footPath) footPath.textContent = '브라우저 메모리 · 새로고침 시 초기화';
|
|
534
|
+
}
|
|
535
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', installChrome);
|
|
536
|
+
else installChrome();
|