careermate 0.0.4 → 0.0.5

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.
@@ -83,6 +83,7 @@ const ICONS = {
83
83
  trash: '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M6 6v14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V6"/>',
84
84
  copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>',
85
85
  download: '<path d="M12 3v12"/><path d="m7 11 5 4 5-4"/><path d="M5 21h14"/>',
86
+ upload: '<path d="M12 21V9"/><path d="m7 13 5-4 5 4"/><path d="M5 3h14"/>',
86
87
  external: '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/>',
87
88
  check: '<path d="m20 6-11 11-5-5"/>',
88
89
  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"/>',
@@ -10,6 +10,12 @@ const COUNT_LABELS = {
10
10
  documents: '문서', cover_letters: '자기소개서', jobs: '채용공고',
11
11
  applications: '지원', interview_preps: '면접 준비',
12
12
  };
13
+ const BACKUP_COUNT_LABELS = {
14
+ ...COUNT_LABELS,
15
+ cover_letter_versions: '자소서 버전',
16
+ fit_analyses: '적합도 분석',
17
+ activities: '활동',
18
+ };
13
19
 
14
20
  export async function render(ctx) {
15
21
  ctx.setActions([]);
@@ -21,7 +27,7 @@ export async function render(ctx) {
21
27
  Theme(),
22
28
  VerifyMode(info),
23
29
  MyData(info.counts),
24
- Backup(backups, () => render(ctx)),
30
+ Backup(backups, async () => { await ctx.refreshNav(); await render(ctx); }),
25
31
  DangerZone(ctx),
26
32
  );
27
33
 
@@ -64,18 +70,42 @@ function DataLocation(info) {
64
70
 
65
71
  /* ---------------------------------------------------------- 연결 상태 */
66
72
  function Connection(info) {
67
- const dashRow = el('div', { class: 'flex between gap-3', style: { padding: '4px 0' } },
68
- el('div', { class: 'flex gap-3', style: { alignItems: 'center' } },
73
+ async function createShortcut() {
74
+ try {
75
+ const { shortcut } = await post('/api/settings/dashboard-shortcut', { open: true });
76
+ toastOk('대시보드 바로가기를 만들었어요.');
77
+ openModal({
78
+ title: '바로가기 생성',
79
+ body: el('div', { class: 'stack-3' },
80
+ el('div', { class: 'callout' },
81
+ icon('info'),
82
+ el('div', {},
83
+ el('div', { class: 'callout__title' }, '폴더를 만들었습니다'),
84
+ el('div', { class: 'callout__body' },
85
+ '대시보드가 실행 중이면 브라우저만 열고, 꺼져 있으면 서버를 시작한 뒤 브라우저를 엽니다.'))),
86
+ el('dl', { class: 'kv' },
87
+ ...kvRow('폴더', shortcut.shortcut_dir, true),
88
+ ...kvRow('바로가기', shortcut.launcher_path, true))),
89
+ footer: (close) => [
90
+ Btn('경로 복사', { icon: 'copy', onClick: () => copyText(shortcut.launcher_path) }),
91
+ Btn('확인', { variant: 'primary', onClick: close }),
92
+ ],
93
+ });
94
+ } catch (e) {
95
+ toastError(e);
96
+ }
97
+ }
98
+
99
+ const dashRow = el('div', { class: 'settings-action-row flex between gap-3', style: { padding: '4px 0' } },
100
+ el('div', { class: 'settings-action-row__main flex gap-3', style: { alignItems: 'center' } },
69
101
  el('span', { class: 'badge badge--final_passed' }, el('span', { class: 'dot' }), '실행 중'),
70
102
  el('div', {},
71
103
  el('div', { class: 'strong' }, '대시보드'),
72
104
  el('div', { class: 'muted text-sm tnum' }, info.url || '—'))),
73
- info.url
74
- ? Btn('새 탭에서 열기', { icon: 'external', sm: true, variant: 'ghost', onClick: () => window.open(info.url, '_blank') })
75
- : null);
105
+ Btn('바로가기 만들기', { icon: 'copy', sm: true, variant: 'ghost', onClick: createShortcut }));
76
106
 
77
- const mcpRow = el('div', { class: 'flex between gap-3', style: { padding: '4px 0', alignItems: 'flex-start' } },
78
- el('div', { class: 'flex gap-3', style: { alignItems: 'flex-start' } },
107
+ const mcpRow = el('div', { class: 'settings-action-row flex between gap-3', style: { padding: '4px 0', alignItems: 'flex-start' } },
108
+ el('div', { class: 'settings-action-row__main flex gap-3', style: { alignItems: 'flex-start' } },
79
109
  el('div', { class: 'empty__icon', style: { width: '30px', height: '30px', margin: 0, borderRadius: '8px' } }, icon('link')),
80
110
  el('div', {},
81
111
  el('div', { class: 'strong' }, 'MCP 서버'),
@@ -193,8 +223,26 @@ function MyData(counts = {}) {
193
223
  });
194
224
  }
195
225
 
196
- /* ------------------------------------------------- 백업 / 내보내기 */
226
+ /* ------------------------------------------------- 내보내기 / 가져오기 */
197
227
  function Backup(backups = [], rerender) {
228
+ const fileInput = el('input', {
229
+ type: 'file',
230
+ accept: '.json,application/json',
231
+ style: { display: 'none' },
232
+ onChange: async (e) => {
233
+ const file = e.target.files?.[0];
234
+ e.target.value = '';
235
+ if (!file) return;
236
+ try {
237
+ const backup = await readBackupFile(file);
238
+ const { preview } = await post('/api/settings/import-preview', { backup });
239
+ openImportModal({ fileName: file.name, backup, preview, rerender });
240
+ } catch (err) {
241
+ toastError(err);
242
+ }
243
+ },
244
+ });
245
+
198
246
  async function createBackup() {
199
247
  try {
200
248
  const res = await post('/api/settings/backup');
@@ -213,15 +261,113 @@ function Backup(backups = [], rerender) {
213
261
  el('td', {}, el('span', { class: 'truncate', title: b.path || b.filename }, b.filename)),
214
262
  el('td', { class: 'muted text-sm' }, fmtRelative(b.created_at)),
215
263
  el('td', { class: 'muted text-sm tnum', style: { textAlign: 'right' } }, `${Math.round((b.size || 0) / 1024)} KB`)))))
216
- : el('p', { class: 'muted', style: { margin: 0 } }, '아직 백업이 없습니다. "백업 생성"으로 현재 데이터를 안전하게 보관하세요.');
264
+ : el('p', { class: 'muted', style: { margin: 0 } }, '아직 로컬 백업이 없습니다. "현재 상태 백업"으로 지금 데이터를 보관하세요.');
217
265
 
218
266
  return Card({
219
- title: '백업 / 내보내기',
267
+ title: '내보내기 / 가져오기',
220
268
  actions: [
221
- Btn('백업 생성', { icon: 'copy', sm: true, variant: 'primary', onClick: createBackup }),
222
- Btn('전체 데이터 내보내기(JSON)', { icon: 'download', sm: true, variant: 'ghost', onClick: () => downloadUrl('/api/settings/export-all') }),
269
+ Btn('내보내기(JSON)', { icon: 'download', sm: true, variant: 'primary', onClick: () => downloadUrl('/api/settings/export-all') }),
270
+ Btn('가져오기(JSON)', { icon: 'upload', sm: true, variant: 'ghost', onClick: () => fileInput.click() }),
271
+ Btn('현재 상태 백업', { icon: 'copy', sm: true, variant: 'ghost', onClick: createBackup }),
223
272
  ],
224
- body: list,
273
+ body: el('div', { class: 'stack-3' }, fileInput, list),
274
+ });
275
+ }
276
+
277
+ function readBackupFile(file) {
278
+ return new Promise((resolve, reject) => {
279
+ const reader = new FileReader();
280
+ reader.onerror = () => reject(new Error('백업 파일을 읽지 못했어요.'));
281
+ reader.onload = () => {
282
+ try {
283
+ resolve(JSON.parse(String(reader.result || '')));
284
+ } catch {
285
+ reject(new Error('JSON 백업 파일만 복원할 수 있어요.'));
286
+ }
287
+ };
288
+ reader.readAsText(file);
289
+ });
290
+ }
291
+
292
+ function fmtBackupDate(iso) {
293
+ if (!iso) return '알 수 없음';
294
+ const d = new Date(iso);
295
+ if (Number.isNaN(d.getTime())) return iso;
296
+ return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
297
+ }
298
+
299
+ function previewRows(counts = {}) {
300
+ const entries = Object.entries(counts)
301
+ .filter(([, count]) => Number(count) > 0)
302
+ .sort(([a], [b]) => (BACKUP_COUNT_LABELS[a] || a).localeCompare(BACKUP_COUNT_LABELS[b] || b, 'ko'));
303
+
304
+ if (!entries.length) {
305
+ return el('p', { class: 'muted', style: { margin: 0 } }, '복원할 항목이 없습니다.');
306
+ }
307
+
308
+ return el('table', { class: 'table' },
309
+ el('thead', {}, el('tr', {}, el('th', {}, '데이터'), el('th', { style: { textAlign: 'right' } }, '항목'))),
310
+ el('tbody', {}, ...entries.map(([key, count]) => el('tr', {},
311
+ el('td', {}, BACKUP_COUNT_LABELS[key] || key),
312
+ el('td', { class: 'tnum', style: { textAlign: 'right' } }, String(count))))));
313
+ }
314
+
315
+ function openImportModal({ fileName, backup, preview, rerender }) {
316
+ let confirmBtn;
317
+ const input = Input({
318
+ placeholder: '가져오기',
319
+ onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !== '가져오기'; },
320
+ onKeydown: (e) => {
321
+ if (e.key === 'Enter' && e.target.value.trim() === '가져오기' && confirmBtn && !confirmBtn.disabled) confirmBtn.click();
322
+ },
323
+ });
324
+
325
+ openModal({
326
+ title: '데이터 가져오기',
327
+ size: 'lg',
328
+ body: el('div', { class: 'stack-3' },
329
+ el('div', { class: 'callout' },
330
+ icon('info'),
331
+ el('div', {},
332
+ el('div', { class: 'callout__title' }, '현재 데이터는 먼저 백업됩니다'),
333
+ el('div', { class: 'callout__body' },
334
+ '가져오면 이 컴퓨터의 CareerMate 데이터가 선택한 JSON 내용으로 전체 교체됩니다.'))),
335
+ el('dl', { class: 'kv' },
336
+ ...kvRow('파일', fileName, false),
337
+ ...kvRow('백업 시각', fmtBackupDate(preview.exported_at), false),
338
+ ...kvRow('스키마', `${preview.version} / 현재 ${preview.current_version}`, false),
339
+ ...kvRow('전체 항목', String(preview.total_rows || 0), false)),
340
+ previewRows(preview.counts),
341
+ preview.warnings?.length
342
+ ? el('div', { class: 'callout' },
343
+ icon('info'),
344
+ el('div', {},
345
+ el('div', { class: 'callout__title' }, '확인 필요'),
346
+ el('div', { class: 'callout__body' }, preview.warnings.join(' '))))
347
+ : null,
348
+ el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } },
349
+ '계속하려면 아래 입력란에 ', el('span', { class: 'strong' }, '가져오기'), ' 를 입력하세요.'),
350
+ input),
351
+ footer: (close) => {
352
+ confirmBtn = Btn('가져오기', {
353
+ icon: 'upload',
354
+ variant: 'danger',
355
+ disabled: true,
356
+ onClick: async () => {
357
+ confirmBtn.disabled = true;
358
+ try {
359
+ await post('/api/settings/restore', { backup, confirm: 'RESTORE' });
360
+ close();
361
+ toastOk('데이터를 가져왔어요.');
362
+ await rerender();
363
+ } catch (e) {
364
+ toastError(e);
365
+ confirmBtn.disabled = input.value.trim() !== '가져오기';
366
+ }
367
+ },
368
+ });
369
+ return [Btn('취소', { onClick: close }), confirmBtn];
370
+ },
225
371
  });
226
372
  }
227
373
 
@@ -190,10 +190,10 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
190
190
 
191
191
  /* ----------------------------------------------------------------- card */
192
192
  .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
193
- .card__head { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
193
+ .card__head { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; min-width: 0; }
194
194
  .card__head h3 { font-size: 15px; }
195
195
  .card__head .sub { color: var(--text-tertiary); font-size: 13px; }
196
- .card__head .right { margin-left: auto; display: flex; gap: 6px; }
196
+ .card__head .right { margin-left: auto; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; min-width: 0; max-width: 100%; }
197
197
  .card__body { padding: 18px; }
198
198
  .card__body--tight { padding: 0; }
199
199
  /* a light bordered block for grouping inside a card/modal — never nest a full .card */
@@ -417,6 +417,10 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
417
417
  /* min-width:0 overrides a grid item's default min-width:auto so the 1fr column
418
418
  can shrink below its content — otherwise a long path/value overflows the grid. */
419
419
  .kv dd { margin: 0; min-width: 0; }
420
+ .settings-action-row { align-items: flex-start; min-width: 0; }
421
+ .settings-action-row__main { flex: 1 1 auto; min-width: 0; }
422
+ .settings-action-row__main > div:last-child { min-width: 0; }
423
+ .settings-action-row > .btn { flex: 0 0 auto; }
420
424
 
421
425
  /* ----------------------------------------------------------------- modal */
422
426
  .modal-root { position: fixed; inset: 0; z-index: 60; display: none; }
@@ -505,5 +509,9 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
505
509
  .kv{ grid-template-columns: 1fr; gap: 0; }
506
510
  .kv dt{ margin-top: 10px; }
507
511
  .kv dd{ margin: 0 0 2px; }
512
+ .settings-action-row{ align-items: stretch; flex-direction: column; }
513
+ .settings-action-row > .btn{ width: 100%; justify-content: center; }
514
+ .card__head{ align-items: flex-start; flex-wrap: wrap; }
515
+ .card__head .right{ margin-left: 0; width: 100%; justify-content: flex-start; }
508
516
  .page-head{ flex-wrap: wrap; }
509
517
  }