careermate 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +22 -17
  2. package/THIRD_PARTY_NOTICES.md +15 -0
  3. package/apps/mcp/src/index.ts +4 -3
  4. package/apps/web/DESIGN_GUIDE.md +2 -2
  5. package/apps/web/demo/demo-shim.js +536 -0
  6. package/apps/web/demo/demo.css +75 -0
  7. package/apps/web/demo/seed.js +269 -0
  8. package/apps/web/public/app.js +34 -2
  9. package/apps/web/public/icon-180.png +0 -0
  10. package/apps/web/public/index.html +14 -3
  11. package/apps/web/public/lib.js +135 -23
  12. package/apps/web/public/pages/applications.js +18 -9
  13. package/apps/web/public/pages/documents.js +66 -50
  14. package/apps/web/public/pages/home.js +12 -11
  15. package/apps/web/public/pages/interview.js +9 -12
  16. package/apps/web/public/pages/jobs.js +39 -36
  17. package/apps/web/public/pages/profile.js +66 -86
  18. package/apps/web/public/pages/settings.js +21 -14
  19. package/apps/web/public/styles.css +105 -38
  20. package/apps/web/src/http.ts +31 -4
  21. package/apps/web/src/info.ts +9 -22
  22. package/apps/web/src/routes.ts +19 -14
  23. package/apps/web/src/server.ts +34 -29
  24. package/apps/web/src/settings.ts +35 -17
  25. package/bin/careermate.mjs +8 -5
  26. package/dist/doctor.mjs +78908 -0
  27. package/dist/install-page/.omc/state/agent-replay-55a58c0d-bb6e-43ea-8a05-4b6c93b80b34.jsonl +12 -0
  28. package/dist/install-page/.omc/state/idle-notif-cooldown.json +3 -0
  29. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/mission-state.json +147 -0
  30. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/pre-tool-advisory-throttle.json +22 -0
  31. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/subagent-tracking-state.json +44 -0
  32. package/dist/install-page/favicon.svg +1 -0
  33. package/dist/install-page/icon-180.png +0 -0
  34. package/dist/install-page/icon-512.png +0 -0
  35. package/dist/install-page/index.html +328 -225
  36. package/dist/install-page/landing.js +54 -0
  37. package/dist/install-page/llms-install.txt +202 -0
  38. package/dist/install-page/og.png +0 -0
  39. package/dist/install-page/shots/applications-dark.png +0 -0
  40. package/dist/install-page/shots/applications-light.png +0 -0
  41. package/dist/install-page/shots/documents-dark.png +0 -0
  42. package/dist/install-page/shots/documents-light.png +0 -0
  43. package/dist/install-page/shots/home-dark.png +0 -0
  44. package/dist/install-page/shots/home-light.png +0 -0
  45. package/dist/install-page/shots/interview-dark.png +0 -0
  46. package/dist/install-page/shots/interview-light.png +0 -0
  47. package/dist/install-page/shots/jobs-dark.png +0 -0
  48. package/dist/install-page/shots/jobs-light.png +0 -0
  49. package/dist/install-page/shots/settings-dark.png +0 -0
  50. package/dist/install-page/shots/settings-light.png +0 -0
  51. package/dist/install-page/start.html +323 -0
  52. package/dist/install-page/style.css +128 -4
  53. package/dist/mcp.mjs +74225 -2007
  54. package/dist/{mcpb-stage/dist/web.mjs → migrate.mjs} +142 -2660
  55. package/dist/pdf.worker.min.mjs +28 -0
  56. package/dist/public/app.js +34 -2
  57. package/dist/public/icon-180.png +0 -0
  58. package/dist/public/index.html +14 -3
  59. package/dist/public/lib.js +135 -23
  60. package/dist/public/pages/applications.js +18 -9
  61. package/dist/public/pages/documents.js +66 -50
  62. package/dist/public/pages/home.js +12 -11
  63. package/dist/public/pages/interview.js +9 -12
  64. package/dist/public/pages/jobs.js +39 -36
  65. package/dist/public/pages/profile.js +66 -86
  66. package/dist/public/pages/settings.js +21 -14
  67. package/dist/public/styles.css +105 -38
  68. package/dist/web.mjs +39950 -369
  69. package/docs/ARCHITECTURE.md +5 -5
  70. package/docs/DECISIONS.md +1 -1
  71. package/docs/DEMO.md +6 -6
  72. package/docs/MCP_TOOLS.md +68 -9
  73. package/docs/ROADMAP.md +11 -10
  74. package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +135 -0
  75. package/docs/START_WORKFLOW.md +19 -7
  76. package/docs/SUPPORTED_AI_APPS.md +85 -33
  77. package/docs/TODO.md +1 -1
  78. package/docs/WORKFLOWS.md +8 -6
  79. package/docs/superpowers/specs/2026-06-16-demo-dashboard-deploy-design.md +64 -0
  80. package/package.json +8 -3
  81. package/packages/core/src/services.ts +42 -1
  82. package/packages/db/src/connection.ts +30 -0
  83. package/packages/db/src/index.ts +1 -0
  84. package/packages/db/src/paths.ts +11 -0
  85. package/packages/db/src/repositories.ts +109 -63
  86. package/packages/db/src/schema.ts +118 -0
  87. package/packages/exporters/src/html.ts +9 -12
  88. package/packages/mcp-tools/src/bridge.ts +55 -32
  89. package/packages/mcp-tools/src/index.ts +9 -0
  90. package/packages/mcp-tools/src/tools.ts +269 -65
  91. package/packages/mcp-tools/src/update.ts +330 -0
  92. package/packages/parsers/src/documents.ts +521 -0
  93. package/packages/parsers/src/index.ts +8 -5
  94. package/packages/prompts/src/onboarding.ts +2 -2
  95. package/packages/shared/src/build-info.ts +22 -0
  96. package/packages/shared/src/enums.ts +39 -0
  97. package/packages/shared/src/index.ts +1 -0
  98. package/packages/shared/src/schemas.ts +24 -3
  99. package/packages/workflows/src/definitions.ts +4 -2
  100. package/scripts/build-demo.mjs +130 -0
  101. package/scripts/build-dist.mjs +46 -8
  102. package/scripts/build-mcpb.mjs +60 -4
  103. package/scripts/doctor.ts +16 -3
  104. package/scripts/gen-og.mjs +86 -0
  105. package/scripts/init.ts +32 -3
  106. package/scripts/seed.ts +5 -5
  107. package/scripts/shots-seed.ts +192 -0
  108. package/scripts/shots.ts +89 -0
  109. package/tsconfig.json +3 -1
  110. package/dist/careermate.mcpb +0 -0
  111. package/dist/mcp-smoke.err +0 -3
  112. package/dist/mcpb-stage/README.md +0 -219
  113. package/dist/mcpb-stage/dist/install-page/index.html +0 -434
  114. package/dist/mcpb-stage/dist/install-page/style.css +0 -407
  115. package/dist/mcpb-stage/dist/install-page/vercel.json +0 -20
  116. package/dist/mcpb-stage/dist/mcp.mjs +0 -23704
  117. package/dist/mcpb-stage/dist/public/app.js +0 -118
  118. package/dist/mcpb-stage/dist/public/index.html +0 -41
  119. package/dist/mcpb-stage/dist/public/lib.js +0 -282
  120. package/dist/mcpb-stage/dist/public/pages/applications.js +0 -98
  121. package/dist/mcpb-stage/dist/public/pages/documents.js +0 -446
  122. package/dist/mcpb-stage/dist/public/pages/home.js +0 -263
  123. package/dist/mcpb-stage/dist/public/pages/interview.js +0 -230
  124. package/dist/mcpb-stage/dist/public/pages/jobs.js +0 -494
  125. package/dist/mcpb-stage/dist/public/pages/profile.js +0 -576
  126. package/dist/mcpb-stage/dist/public/pages/settings.js +0 -233
  127. package/dist/mcpb-stage/dist/public/styles.css +0 -420
  128. package/dist/mcpb-stage/manifest.json +0 -40
  129. package/docs/INSTALL.md +0 -148
  130. package/docs/INSTALL_AND_USAGE.md +0 -99
  131. package/install-page/index.html +0 -474
  132. package/install-page/style.css +0 -391
  133. package/install-page/vercel.json +0 -20
  134. /package/dist/{mcpb-stage/dist/public → install-page}/fonts/PretendardVariable.woff2 +0 -0
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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();
@@ -0,0 +1,75 @@
1
+ /* Demo-only overlay: a slim TOP bar that frames the page as a no-save preview
2
+ and links back to the marketing site, plus a theme toggle. Uses the dashboard's
3
+ own theme tokens so it adapts to light/dark automatically.
4
+
5
+ The dashboard layout is a full-height grid with a sticky sidebar + sticky
6
+ topbar; we reserve --demo-bar-h at the top and push those sticky anchors down
7
+ so nothing hides behind the bar. */
8
+
9
+ :root { --demo-bar-h: 46px; }
10
+
11
+ #demo-bar {
12
+ position: fixed;
13
+ top: 0;
14
+ left: 0;
15
+ right: 0;
16
+ height: var(--demo-bar-h);
17
+ z-index: 55; /* above sidebar drawer (50), below modals (60) */
18
+ display: flex;
19
+ align-items: center;
20
+ gap: 12px;
21
+ padding: 0 16px;
22
+ background: color-mix(in srgb, var(--bg) 90%, transparent);
23
+ backdrop-filter: blur(8px);
24
+ border-bottom: 1px solid var(--border);
25
+ font-size: 13px;
26
+ }
27
+
28
+ .demo-bar__brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--text); flex: none; }
29
+ .demo-bar__logo {
30
+ width: 24px; height: 24px; border-radius: 6px; flex: none;
31
+ background: var(--accent); color: var(--text-on-accent);
32
+ display: grid; place-items: center; font-weight: 800; font-size: 13px;
33
+ }
34
+ .demo-bar__name { font-weight: 700; font-size: 14px; letter-spacing: -.01em; }
35
+ .demo-bar__badge {
36
+ font-weight: 700; font-size: 11px; color: var(--accent);
37
+ background: var(--accent-soft); border: 1px solid var(--accent-border);
38
+ padding: 2px 7px; border-radius: 999px;
39
+ }
40
+ .demo-bar__text { color: var(--text-secondary); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
41
+ .demo-bar__spacer { flex: 1 1 auto; }
42
+
43
+ .demo-bar__icon {
44
+ flex: none; width: 32px; height: 32px; display: grid; place-items: center;
45
+ border: 1px solid var(--border); background: var(--surface); border-radius: 8px;
46
+ color: var(--text-secondary); cursor: pointer;
47
+ }
48
+ .demo-bar__icon:hover { background: var(--surface-hover); color: var(--text); }
49
+
50
+ .demo-bar__link {
51
+ flex: none; font-weight: 600; color: var(--text-secondary); text-decoration: none;
52
+ padding: 6px 11px; border-radius: 8px; white-space: nowrap;
53
+ }
54
+ .demo-bar__link:hover { background: var(--surface-hover); color: var(--text); }
55
+ .demo-bar__link--primary {
56
+ color: var(--accent); border: 1px solid var(--accent-border);
57
+ }
58
+ .demo-bar__link--primary:hover { background: var(--accent-soft); color: var(--accent); }
59
+
60
+ /* Reserve space for the bar by pushing the app + its sticky anchors down. */
61
+ body.has-demo-bar .app { margin-top: var(--demo-bar-h); }
62
+ body.has-demo-bar .sidebar { top: var(--demo-bar-h); height: calc(100vh - var(--demo-bar-h)); }
63
+ body.has-demo-bar .topbar { top: var(--demo-bar-h); }
64
+
65
+ @media (max-width: 900px) {
66
+ /* Sidebar is an off-canvas drawer here — start it below the bar too. */
67
+ body.has-demo-bar .sidebar { top: var(--demo-bar-h); height: calc(100% - var(--demo-bar-h)); }
68
+ }
69
+
70
+ @media (max-width: 640px) {
71
+ /* Tight screens: drop the explanatory sentence, keep brand + actions. */
72
+ .demo-bar__text { display: none; }
73
+ .demo-bar__link[href="/#install"] { display: none; }
74
+ #demo-bar { gap: 8px; padding: 0 10px; }
75
+ }