careermate 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +11 -7
- package/INSTALL.md +18 -16
- package/README.md +28 -26
- package/THIRD_PARTY_NOTICES.md +2 -3
- package/bin/careermate.mjs +8 -1
- package/dist/career-os/README.md +8 -3
- package/dist/career-os/eop/career-description.md +136 -0
- package/dist/career-os/eop/cover-letter.md +7 -5
- package/dist/career-os/eop/fit-analysis.md +15 -5
- package/dist/career-os/eop/interview-prep.md +209 -21
- package/dist/career-os/eop/job-analysis.md +5 -3
- package/dist/career-os/eop/profile-extraction.md +5 -4
- package/dist/career-os/knowledge/cover-letter.md +4 -4
- package/dist/career-os/knowledge/fit-matching.md +8 -8
- package/dist/career-os/knowledge/interview-behavioral.md +76 -8
- package/dist/career-os/knowledge/interview-technical.md +106 -4
- package/dist/career-os/knowledge/linkedin-profile.md +1 -1
- package/dist/career-os/knowledge/networking-referrals.md +1 -1
- package/dist/career-os/knowledge/offer-evaluation-decision.md +10 -10
- package/dist/career-os/knowledge/portfolio.md +3 -3
- package/dist/career-os/knowledge/verifiers/ats-compat.md +8 -4
- package/dist/career-os/knowledge/verifiers/human-voice.md +2 -2
- package/dist/career-os/knowledge/verifiers/recency-staleness.md +1 -1
- package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
- package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -1
- package/dist/doctor.mjs +1019 -127
- package/dist/mcp.mjs +1036 -126
- package/dist/migrate.mjs +52 -1
- package/dist/public/app.js +42 -16
- package/dist/public/i18n/en/applications.js +23 -0
- package/dist/public/i18n/en/common.js +22 -0
- package/dist/public/i18n/en/documents.js +133 -0
- package/dist/public/i18n/en/home.js +75 -0
- package/dist/public/i18n/en/interview.js +69 -0
- package/dist/public/i18n/en/jobs.js +152 -0
- package/dist/public/i18n/en/nav.js +18 -0
- package/dist/public/i18n/en/profile.js +201 -0
- package/dist/public/i18n/en/settings.js +127 -0
- package/dist/public/i18n/en/status.js +22 -0
- package/dist/public/i18n/en.js +17 -0
- package/dist/public/i18n/ko/applications.js +23 -0
- package/dist/public/i18n/ko/common.js +22 -0
- package/dist/public/i18n/ko/documents.js +133 -0
- package/dist/public/i18n/ko/home.js +63 -0
- package/dist/public/i18n/ko/interview.js +69 -0
- package/dist/public/i18n/ko/jobs.js +152 -0
- package/dist/public/i18n/ko/nav.js +18 -0
- package/dist/public/i18n/ko/profile.js +201 -0
- package/dist/public/i18n/ko/settings.js +127 -0
- package/dist/public/i18n/ko/status.js +23 -0
- package/dist/public/i18n/ko.js +18 -0
- package/dist/public/i18n.js +55 -0
- package/dist/public/index.html +1 -0
- package/dist/public/lib.js +25 -17
- package/dist/public/pages/applications.js +17 -21
- package/dist/public/pages/documents.js +153 -107
- package/dist/public/pages/home.js +88 -53
- package/dist/public/pages/interview.js +105 -51
- package/dist/public/pages/jobs.js +330 -106
- package/dist/public/pages/profile.js +157 -152
- package/dist/public/pages/settings.js +270 -72
- package/dist/public/styles.css +38 -3
- package/dist/web.mjs +859 -103
- package/docs/FAQ.md +26 -5
- package/docs/MCP_TOOLS.md +26 -12
- package/docs/START_WORKFLOW.md +2 -2
- package/docs/SUPPORTED_AI_APPS.md +13 -0
- package/docs/WORKFLOWS.md +80 -23
- package/docs/career-os/README.md +8 -3
- package/docs/career-os/eop/career-description.md +136 -0
- package/docs/career-os/eop/cover-letter.md +7 -5
- package/docs/career-os/eop/fit-analysis.md +15 -5
- package/docs/career-os/eop/interview-prep.md +209 -21
- package/docs/career-os/eop/job-analysis.md +5 -3
- package/docs/career-os/eop/profile-extraction.md +5 -4
- package/docs/career-os/knowledge/cover-letter.md +4 -4
- package/docs/career-os/knowledge/fit-matching.md +8 -8
- package/docs/career-os/knowledge/interview-behavioral.md +76 -8
- package/docs/career-os/knowledge/interview-technical.md +106 -4
- package/docs/career-os/knowledge/linkedin-profile.md +1 -1
- package/docs/career-os/knowledge/networking-referrals.md +1 -1
- package/docs/career-os/knowledge/offer-evaluation-decision.md +10 -10
- package/docs/career-os/knowledge/portfolio.md +3 -3
- package/docs/career-os/knowledge/verifiers/ats-compat.md +8 -4
- package/docs/career-os/knowledge/verifiers/human-voice.md +2 -2
- package/docs/career-os/knowledge/verifiers/recency-staleness.md +1 -1
- package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
- package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -1
- package/package.json +2 -2
- package/packages/core/src/onboarding.ts +2 -1
- package/packages/core/src/services.ts +138 -21
- package/packages/core/src/summary.ts +59 -1
- package/packages/core/src/verify/lint.ts +4 -0
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/repositories.ts +0 -0
- package/packages/db/src/schema.ts +17 -0
- package/packages/exporters/src/index.ts +28 -17
- package/packages/knowledge/src/index.ts +46 -7
- package/packages/mcp-tools/src/bridge.ts +194 -13
- package/packages/mcp-tools/src/tools.ts +390 -38
- package/packages/mcp-tools/src/update.ts +31 -16
- package/packages/parsers/src/documents.ts +12 -1
- package/packages/prompts/src/humanize.ts +46 -0
- package/packages/prompts/src/index.ts +5 -1
- package/packages/prompts/src/install.ts +4 -4
- package/packages/prompts/src/onboarding.ts +4 -4
- package/packages/prompts/src/system.ts +17 -8
- package/packages/shared/src/enums.ts +14 -4
- package/packages/shared/src/schemas.ts +41 -0
- package/packages/workflows/src/definitions.ts +47 -10
- package/scripts/init.ts +284 -13
- package/dist/site/analytics.js +0 -97
- package/dist/site/favicon.svg +0 -1
- package/dist/site/fonts/PretendardVariable.woff2 +0 -0
- package/dist/site/icon-180.png +0 -0
- package/dist/site/icon-512.png +0 -0
- package/dist/site/index.html +0 -565
- package/dist/site/landing.js +0 -54
- package/dist/site/llms-install.txt +0 -218
- package/dist/site/robots.txt +0 -4
- package/dist/site/sitemap.xml +0 -21
- package/dist/site/start.html +0 -353
- package/dist/site/style.css +0 -559
- package/dist/site/vercel.json +0 -20
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// 설정 페이지.
|
|
2
|
+
export default {
|
|
3
|
+
// 공통
|
|
4
|
+
'settings.copy': '복사',
|
|
5
|
+
'settings.confirm': '확인',
|
|
6
|
+
'settings.cancel': '취소',
|
|
7
|
+
'settings.copyPath': '경로 복사',
|
|
8
|
+
|
|
9
|
+
// 데이터 저장 위치
|
|
10
|
+
'settings.dataLocation.title': '데이터 저장 위치',
|
|
11
|
+
'settings.dataLocation.dataDir': '데이터 폴더',
|
|
12
|
+
'settings.dataLocation.database': '데이터베이스',
|
|
13
|
+
'settings.dataLocation.appVersion': '앱 버전',
|
|
14
|
+
'settings.dataLocation.nodeVersion': 'Node 버전',
|
|
15
|
+
'settings.dataLocation.privacyTitle': '내 컴퓨터에만 저장됩니다',
|
|
16
|
+
'settings.dataLocation.privacyBody': '모든 데이터는 이 컴퓨터에만 저장됩니다. 외부로 전송되지 않습니다. AI에는 사용자가 직접 입력하거나 AI가 MCP로 조회한 정보만 전달됩니다.',
|
|
17
|
+
|
|
18
|
+
// 연결 상태
|
|
19
|
+
'settings.connection.title': '연결 상태',
|
|
20
|
+
'settings.connection.shortcutDemoToast': '데모 바로가기 동작을 미리 보여드려요.',
|
|
21
|
+
'settings.connection.shortcutToast': '대시보드 바로가기를 만들었어요.',
|
|
22
|
+
'settings.connection.shortcutDemoTitle': '바로가기 미리보기',
|
|
23
|
+
'settings.connection.shortcutTitle': '바로가기 생성',
|
|
24
|
+
'settings.connection.shortcutDemoCalloutTitle': '데모에서는 파일을 만들지 않습니다',
|
|
25
|
+
'settings.connection.shortcutCalloutTitle': '폴더를 만들었습니다',
|
|
26
|
+
'settings.connection.shortcutDemoCalloutBody': '실제 앱에서는 대시보드 바로가기 폴더와 실행 파일을 만들고, 여기서는 경로 예시만 보여줍니다.',
|
|
27
|
+
'settings.connection.shortcutCalloutBody': '대시보드가 실행 중이면 브라우저만 열고, 꺼져 있으면 서버를 시작한 뒤 브라우저를 엽니다.',
|
|
28
|
+
'settings.connection.shortcutFolder': '폴더',
|
|
29
|
+
'settings.connection.shortcutLauncher': '바로가기',
|
|
30
|
+
'settings.connection.running': '실행 중',
|
|
31
|
+
'settings.connection.dashboard': '대시보드',
|
|
32
|
+
'settings.connection.createShortcut': '바로가기 만들기',
|
|
33
|
+
'settings.connection.mcpServer': 'MCP 서버',
|
|
34
|
+
'settings.connection.mcpBody': 'MCP 서버는 사용하시는 AI 클라이언트(Claude·ChatGPT·Cursor)가 직접 실행하며, 이 대시보드와 같은 데이터베이스를 공유합니다.',
|
|
35
|
+
'settings.connection.openInstall': '설치·연결 안내 열기',
|
|
36
|
+
|
|
37
|
+
// 테마
|
|
38
|
+
'settings.theme.title': '테마',
|
|
39
|
+
'settings.theme.sub': '시스템 설정을 따르거나 직접 고를 수 있어요',
|
|
40
|
+
'settings.theme.light': '라이트',
|
|
41
|
+
'settings.theme.dark': '다크',
|
|
42
|
+
'settings.theme.system': '시스템',
|
|
43
|
+
|
|
44
|
+
// 자소서 수치 점검 모드
|
|
45
|
+
'settings.verify.title': '자소서 수치 점검',
|
|
46
|
+
'settings.verify.sub': 'AI가 쓴 수치가 내 실제 데이터에 근거가 있는지 저장 전에 자동으로 확인합니다',
|
|
47
|
+
'settings.verify.basic': '기본',
|
|
48
|
+
'settings.verify.strict': '엄격',
|
|
49
|
+
'settings.verify.basicDesc': '저장된 경력·이력서·프로젝트에 아예 근거가 없는 수치만 차단',
|
|
50
|
+
'settings.verify.strictDesc': '이력서 본문에 없고 구조화 경력/프로젝트 항목에만 있는 수치까지 차단',
|
|
51
|
+
'settings.verify.strictOnToast': '엄격 모드를 켰어요.',
|
|
52
|
+
'settings.verify.strictOffToast': '엄격 모드를 껐어요.',
|
|
53
|
+
'settings.verify.note': '엄격 모드에선 올린 이력서에 없는 수치가 막힐 수 있어요. 막히면 이력서를 올리거나 모드를 끄세요. AI에게 "엄격하게 봐줘"라고 해도 켤 수 있습니다.',
|
|
54
|
+
|
|
55
|
+
// 내 데이터
|
|
56
|
+
'settings.myData.title': '내 데이터',
|
|
57
|
+
'settings.myData.sub': '이 컴퓨터에 저장된 항목 수',
|
|
58
|
+
|
|
59
|
+
// 항목 라벨 (COUNT_LABELS / BACKUP_COUNT_LABELS)
|
|
60
|
+
'settings.count.profile': '프로필',
|
|
61
|
+
'settings.count.experiences': '경력',
|
|
62
|
+
'settings.count.projects': '프로젝트',
|
|
63
|
+
'settings.count.skills': '기술',
|
|
64
|
+
'settings.count.documents': '문서',
|
|
65
|
+
'settings.count.cover_letters': '자기소개서',
|
|
66
|
+
'settings.count.jobs': '채용공고',
|
|
67
|
+
'settings.count.applications': '지원',
|
|
68
|
+
'settings.count.interview_preps': '면접 준비',
|
|
69
|
+
'settings.count.cover_letter_versions': '자소서 버전',
|
|
70
|
+
'settings.count.fit_analyses': '적합도 분석',
|
|
71
|
+
'settings.count.activities': '활동',
|
|
72
|
+
|
|
73
|
+
// 내보내기 / 가져오기
|
|
74
|
+
'settings.backup.title': '내보내기 / 가져오기',
|
|
75
|
+
'settings.backup.export': '내보내기(JSON)',
|
|
76
|
+
'settings.backup.import': '가져오기(JSON)',
|
|
77
|
+
'settings.backup.create': '현재 상태 백업',
|
|
78
|
+
'settings.backup.createToast': '백업을 생성했어요.',
|
|
79
|
+
'settings.backup.colFile': '파일',
|
|
80
|
+
'settings.backup.colCreated': '생성',
|
|
81
|
+
'settings.backup.colSize': '크기',
|
|
82
|
+
'settings.backup.size': '{{kb}} KB',
|
|
83
|
+
'settings.backup.empty': '아직 로컬 백업이 없습니다. "현재 상태 백업"으로 지금 데이터를 보관하세요.',
|
|
84
|
+
'settings.backup.readError': '백업 파일을 읽지 못했어요.',
|
|
85
|
+
'settings.backup.parseError': 'JSON 백업 파일만 복원할 수 있어요.',
|
|
86
|
+
'settings.backup.unknownDate': '알 수 없음',
|
|
87
|
+
|
|
88
|
+
// 가져오기 미리보기 표
|
|
89
|
+
'settings.preview.empty': '복원할 항목이 없습니다.',
|
|
90
|
+
'settings.preview.colData': '데이터',
|
|
91
|
+
'settings.preview.colCount': '항목',
|
|
92
|
+
|
|
93
|
+
// 가져오기 모달
|
|
94
|
+
'settings.import.gateWord': '가져오기',
|
|
95
|
+
'settings.import.title': '데이터 가져오기',
|
|
96
|
+
'settings.import.calloutTitle': '현재 데이터는 먼저 백업됩니다',
|
|
97
|
+
'settings.import.calloutBody': '가져오면 이 컴퓨터의 CareerMate 데이터가 선택한 JSON 내용으로 전체 교체됩니다.',
|
|
98
|
+
'settings.import.file': '파일',
|
|
99
|
+
'settings.import.exportedAt': '백업 시각',
|
|
100
|
+
'settings.import.schema': '스키마',
|
|
101
|
+
'settings.import.schemaValue': '{{version}} / 현재 {{current}}',
|
|
102
|
+
'settings.import.totalRows': '전체 항목',
|
|
103
|
+
'settings.import.warningsTitle': '확인 필요',
|
|
104
|
+
'settings.import.instruction': '계속하려면 아래 입력란에 {{word}} 를 입력하세요.',
|
|
105
|
+
'settings.import.confirm': '가져오기',
|
|
106
|
+
'settings.import.doneToast': '데이터를 가져왔어요.',
|
|
107
|
+
|
|
108
|
+
// 위험 구역
|
|
109
|
+
'settings.reset.gateWord': '초기화',
|
|
110
|
+
'settings.reset.title': '모든 데이터 초기화',
|
|
111
|
+
'settings.reset.calloutTitle': '되돌릴 수 없습니다',
|
|
112
|
+
'settings.reset.calloutBody': '프로필·공고·지원·문서·면접 준비를 포함한 모든 데이터가 영구적으로 삭제됩니다. 안전을 위해 초기화 직전에 자동으로 백업이 한 번 생성됩니다.',
|
|
113
|
+
'settings.reset.instruction': '계속하려면 아래 입력란에 {{word}} 를 입력하세요.',
|
|
114
|
+
'settings.reset.confirm': '모든 데이터 초기화',
|
|
115
|
+
'settings.reset.doneToast': '모든 데이터를 초기화했어요.',
|
|
116
|
+
'settings.danger.title': '위험 구역',
|
|
117
|
+
'settings.danger.sub': '이 작업은 되돌릴 수 없습니다',
|
|
118
|
+
'settings.danger.resetTitle': '모든 데이터 초기화',
|
|
119
|
+
'settings.danger.resetBody': '이 컴퓨터에 저장된 모든 CareerMate 데이터를 삭제합니다. 초기화 전에 자동으로 백업이 생성됩니다.',
|
|
120
|
+
|
|
121
|
+
// help / report a problem
|
|
122
|
+
'settings.support.title': '도움말·문제 신고',
|
|
123
|
+
'settings.support.sub': '버그는 GitHub 이슈로, 사용법 질문은 Discussions로, 그 밖의 문의는 이메일로 보내 주세요.',
|
|
124
|
+
'settings.support.report': '문제 신고',
|
|
125
|
+
'settings.support.ask': '질문하기',
|
|
126
|
+
'settings.support.email': '이메일 문의',
|
|
127
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// enum 라벨(클라이언트 현지화). 키는 CODE와 동일 — 서버는 코드를 내려주고 라벨은 클라가 t()로.
|
|
2
|
+
// 이 키들은 packages/shared/src/enums.ts의 CODE와 1:1로 맞춘다.
|
|
3
|
+
export default {
|
|
4
|
+
// application status
|
|
5
|
+
'status.draft': '작성 중',
|
|
6
|
+
'status.planned': '지원 예정',
|
|
7
|
+
'status.applied': '지원 완료',
|
|
8
|
+
'status.document_passed': '서류 합격',
|
|
9
|
+
'status.interview': '면접 진행',
|
|
10
|
+
'status.final_passed': '최종 합격',
|
|
11
|
+
'status.rejected': '불합격',
|
|
12
|
+
'status.on_hold': '보류',
|
|
13
|
+
// document kind
|
|
14
|
+
'kind.resume': '이력서',
|
|
15
|
+
'kind.career_description': '경력기술서',
|
|
16
|
+
'kind.portfolio': '포트폴리오',
|
|
17
|
+
'kind.other': '기타 문서',
|
|
18
|
+
// content source
|
|
19
|
+
'source.manual': '직접 입력',
|
|
20
|
+
'source.upload': '파일 업로드',
|
|
21
|
+
'source.ai': 'AI 생성',
|
|
22
|
+
'source.edit': '직접 수정',
|
|
23
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// 한국어 카탈로그 — 영역별 모듈을 합쳐 단일 플랫 맵으로 export.
|
|
2
|
+
// 새 로케일/영역 추가 = 파일 1개 + import 1줄. 가드(check-i18n-keys)는 이 합본 키집합을 검사한다.
|
|
3
|
+
// ko엔 복수 객체 금지(CLDR 'other'만) — 카운트는 카운터 접미사 문자열로.
|
|
4
|
+
import common from './ko/common.js';
|
|
5
|
+
import nav from './ko/nav.js';
|
|
6
|
+
import status from './ko/status.js';
|
|
7
|
+
import settings from './ko/settings.js';
|
|
8
|
+
import home from './ko/home.js';
|
|
9
|
+
import jobs from './ko/jobs.js';
|
|
10
|
+
import documents from './ko/documents.js';
|
|
11
|
+
import profile from './ko/profile.js';
|
|
12
|
+
import interview from './ko/interview.js';
|
|
13
|
+
import applications from './ko/applications.js';
|
|
14
|
+
|
|
15
|
+
export default {
|
|
16
|
+
...common, ...nav, ...status, ...settings,
|
|
17
|
+
...home, ...jobs, ...documents, ...profile, ...interview, ...applications,
|
|
18
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// apps/web/public/i18n.js
|
|
2
|
+
// 로케일 리졸버 + t(). lib.js에 의존하지 않는다(순환 방지: lib.js가 i18n.js를 import).
|
|
3
|
+
// 기존 테마 설정과 동일한 계약: localStorage['careermate-lang'], navigator.language 감지.
|
|
4
|
+
import ko from '/i18n/ko.js';
|
|
5
|
+
import en from '/i18n/en.js';
|
|
6
|
+
|
|
7
|
+
const CATALOGS = { ko, en };
|
|
8
|
+
const FALLBACK = 'ko';
|
|
9
|
+
export const LOCALES = Object.keys(CATALOGS); // ['ko','en'] — 3번째 = import 1줄 + 맵 1줄
|
|
10
|
+
|
|
11
|
+
function detect() {
|
|
12
|
+
try {
|
|
13
|
+
const s = localStorage.getItem('careermate-lang');
|
|
14
|
+
if (s && CATALOGS[s]) return s;
|
|
15
|
+
} catch (e) {
|
|
16
|
+
/* private mode / storage 비활성 */
|
|
17
|
+
}
|
|
18
|
+
return (navigator.language || '').toLowerCase().startsWith('ko') ? 'ko' : 'en';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let lang = detect();
|
|
22
|
+
// Reflect the active locale on <html lang> at module init. This lives in an
|
|
23
|
+
// external module (not an inline <script>) so it satisfies the dashboard CSP
|
|
24
|
+
// (script-src 'self'). The lang attribute has no visual FOUC, so setting it as
|
|
25
|
+
// the module graph boots (before the app renders) is soon enough.
|
|
26
|
+
try { document.documentElement.lang = lang; } catch (e) { /* SSR/no-DOM */ }
|
|
27
|
+
const subs = new Set();
|
|
28
|
+
|
|
29
|
+
export const getLang = () => lang;
|
|
30
|
+
|
|
31
|
+
export function setLang(next) {
|
|
32
|
+
if (!CATALOGS[next] || next === lang) return;
|
|
33
|
+
lang = next;
|
|
34
|
+
try {
|
|
35
|
+
localStorage.setItem('careermate-lang', next);
|
|
36
|
+
} catch (e) {
|
|
37
|
+
/* best-effort */
|
|
38
|
+
}
|
|
39
|
+
document.documentElement.lang = next;
|
|
40
|
+
subs.forEach((fn) => fn(lang)); // 라우터가 현재 뷰를 재렌더
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function onLangChange(fn) {
|
|
44
|
+
subs.add(fn);
|
|
45
|
+
return () => subs.delete(fn);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const _pr = {};
|
|
49
|
+
const pr = (l) => (_pr[l] ||= new Intl.PluralRules(l));
|
|
50
|
+
|
|
51
|
+
export function t(key, vars) {
|
|
52
|
+
let m = CATALOGS[lang]?.[key] ?? CATALOGS[FALLBACK]?.[key] ?? key; // 절대 빈 문자열 아님
|
|
53
|
+
if (vars && typeof m === 'object') m = m[pr(lang).select(vars.count)] ?? m.other ?? key;
|
|
54
|
+
return vars ? String(m).replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] ?? `{{${k}}}`)) : m;
|
|
55
|
+
}
|
package/dist/public/index.html
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
<div><span class="dot"></span><span id="foot-status">로컬에서 실행 중</span></div>
|
|
30
30
|
<div id="foot-path" class="truncate" style="margin-top:4px"></div>
|
|
31
31
|
<a class="sidebar__foot-link" href="https://github.com/osntak/careermate" target="_blank" rel="noopener" title="GitHub에서 소스 보기"><svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg><span>GitHub</span></a>
|
|
32
|
+
<a class="sidebar__foot-link" id="foot-faq" href="https://careermate.life/faq" target="_blank" rel="noopener" title="자주 묻는 질문"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.82 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg><span>FAQ</span></a>
|
|
32
33
|
</div>
|
|
33
34
|
</aside>
|
|
34
35
|
|
package/dist/public/lib.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// (résumé/cover-letter text) is always rendered as text. Only our own trusted
|
|
8
8
|
// icon SVG strings use innerHTML.
|
|
9
9
|
// =============================================================================
|
|
10
|
+
import { t, getLang } from '/i18n.js';
|
|
10
11
|
|
|
11
12
|
/* --------------------------------------------------------------- DOM helper */
|
|
12
13
|
|
|
@@ -83,6 +84,7 @@ const ICONS = {
|
|
|
83
84
|
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
85
|
copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>',
|
|
85
86
|
download: '<path d="M12 3v12"/><path d="m7 11 5 4 5-4"/><path d="M5 21h14"/>',
|
|
87
|
+
upload: '<path d="M12 21V9"/><path d="m7 13 5-4 5 4"/><path d="M5 3h14"/>',
|
|
86
88
|
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
89
|
check: '<path d="m20 6-11 11-5-5"/>',
|
|
88
90
|
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"/>',
|
|
@@ -93,6 +95,7 @@ const ICONS = {
|
|
|
93
95
|
inbox: '<path d="M3 12h5l2 3h4l2-3h5"/><path d="M5 6h14l2 6v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7Z"/>',
|
|
94
96
|
target: '<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r="0.5"/>',
|
|
95
97
|
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>',
|
|
98
|
+
mail: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
|
|
96
99
|
chevronRight: '<path d="m9 6 6 6-6 6"/>',
|
|
97
100
|
};
|
|
98
101
|
/** Returns an <svg> element for the given icon name. */
|
|
@@ -130,7 +133,7 @@ export async function api(method, path, body) {
|
|
|
130
133
|
});
|
|
131
134
|
} catch {
|
|
132
135
|
// Connection failure (server stopped, machine asleep) — never surface "Failed to fetch".
|
|
133
|
-
throw new Error('
|
|
136
|
+
throw new Error(t('err.connect'));
|
|
134
137
|
}
|
|
135
138
|
const text = await res.text();
|
|
136
139
|
let data = {};
|
|
@@ -144,10 +147,10 @@ export async function api(method, path, body) {
|
|
|
144
147
|
}
|
|
145
148
|
// Friendly fallback when the server didn't supply a message — never show a raw status code.
|
|
146
149
|
function friendlyStatus(status) {
|
|
147
|
-
if (status === 404) return '
|
|
148
|
-
if (status === 403) return '
|
|
149
|
-
if (status >= 500) return '
|
|
150
|
-
return '
|
|
150
|
+
if (status === 404) return t('err.404');
|
|
151
|
+
if (status === 403) return t('err.403');
|
|
152
|
+
if (status >= 500) return t('err.500');
|
|
153
|
+
return t('err.generic');
|
|
151
154
|
}
|
|
152
155
|
export const get = (p) => api('GET', p);
|
|
153
156
|
export const post = (p, b) => api('POST', p, b);
|
|
@@ -155,19 +158,24 @@ export const put = (p, b) => api('PUT', p, b);
|
|
|
155
158
|
export const del = (p) => api('DELETE', p);
|
|
156
159
|
|
|
157
160
|
/* ------------------------------------------------------------- formatters */
|
|
161
|
+
// Locale-aware date/relative-time via Intl, cached per active locale.
|
|
162
|
+
const _dtf = {};
|
|
163
|
+
const _rtf = {};
|
|
164
|
+
const dateFmt = () => (_dtf[getLang()] ||= new Intl.DateTimeFormat(getLang(), { year: 'numeric', month: 'short', day: 'numeric' }));
|
|
165
|
+
const relFmt = () => (_rtf[getLang()] ||= new Intl.RelativeTimeFormat(getLang(), { numeric: 'auto' }));
|
|
158
166
|
export function fmtDate(iso) {
|
|
159
167
|
if (!iso) return '';
|
|
160
168
|
const d = new Date(iso);
|
|
161
169
|
if (isNaN(d)) return iso;
|
|
162
|
-
return
|
|
170
|
+
return dateFmt().format(d);
|
|
163
171
|
}
|
|
164
172
|
export function fmtRelative(iso) {
|
|
165
173
|
if (!iso) return '';
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
168
|
-
if (
|
|
169
|
-
if (
|
|
170
|
-
if (
|
|
174
|
+
const sec = (Date.now() - new Date(iso).getTime()) / 1000;
|
|
175
|
+
if (sec < 60) return t('common.justNow');
|
|
176
|
+
if (sec < 3600) return relFmt().format(-Math.floor(sec / 60), 'minute');
|
|
177
|
+
if (sec < 86400) return relFmt().format(-Math.floor(sec / 3600), 'hour');
|
|
178
|
+
if (sec < 604800) return relFmt().format(-Math.floor(sec / 86400), 'day');
|
|
171
179
|
return fmtDate(iso);
|
|
172
180
|
}
|
|
173
181
|
export function scoreClass(score) {
|
|
@@ -310,7 +318,7 @@ export function toast(message, { title, type = 'default', timeout = 3800 } = {})
|
|
|
310
318
|
toastRoot.append(t);
|
|
311
319
|
setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .25s'; setTimeout(() => t.remove(), 260); }, timeout);
|
|
312
320
|
}
|
|
313
|
-
export function toastError(e) { toast(e instanceof Error ? e.message : String(e), { title: '
|
|
321
|
+
export function toastError(e) { toast(e instanceof Error ? e.message : String(e), { title: t('toast.errorTitle'), type: 'error' }); }
|
|
314
322
|
export function toastOk(msg) { toast(msg, { type: 'success' }); }
|
|
315
323
|
|
|
316
324
|
/* ------------------------------------------------------------------ modal */
|
|
@@ -338,7 +346,7 @@ export function openModal({ title, body, footer, size }) {
|
|
|
338
346
|
const root = ensureModalRoot();
|
|
339
347
|
const close = closeModal;
|
|
340
348
|
modalReturnFocus = document.activeElement; // restored by closeModal
|
|
341
|
-
const closeBtn = IconBtn('plus', { title: '
|
|
349
|
+
const closeBtn = IconBtn('plus', { title: t('common.close'), onClick: close }); // rotated to look like ×
|
|
342
350
|
closeBtn.style.transform = 'rotate(45deg)';
|
|
343
351
|
const modal = el('div', {
|
|
344
352
|
class: `modal${size === 'lg' ? ' modal--lg' : ''}`,
|
|
@@ -369,13 +377,13 @@ export function openModal({ title, body, footer, size }) {
|
|
|
369
377
|
setTimeout(() => (bodyNode.querySelector('input,textarea,select') || modal.querySelector(MODAL_FOCUSABLE))?.focus(), 30);
|
|
370
378
|
}
|
|
371
379
|
/** Confirmation dialog. Returns a Promise<boolean>. */
|
|
372
|
-
export function confirmDialog({ title = '
|
|
380
|
+
export function confirmDialog({ title = t('common.confirm'), message, confirmLabel = t('common.confirm'), danger } = {}) {
|
|
373
381
|
return new Promise((resolve) => {
|
|
374
382
|
openModal({
|
|
375
383
|
title,
|
|
376
384
|
body: el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } }, message),
|
|
377
385
|
footer: (close) => [
|
|
378
|
-
Btn('
|
|
386
|
+
Btn(t('common.cancel'), { onClick: () => { close(); resolve(false); } }),
|
|
379
387
|
Btn(confirmLabel, { variant: danger ? 'danger' : 'primary', onClick: () => { close(); resolve(true); } }),
|
|
380
388
|
],
|
|
381
389
|
});
|
|
@@ -384,8 +392,8 @@ export function confirmDialog({ title = '확인', message, confirmLabel = '확
|
|
|
384
392
|
|
|
385
393
|
/* ---------------------------------------------------------------- utility */
|
|
386
394
|
export async function copyText(text) {
|
|
387
|
-
try { await navigator.clipboard.writeText(text); toastOk('
|
|
388
|
-
catch { toast('
|
|
395
|
+
try { await navigator.clipboard.writeText(text); toastOk(t('toast.copied')); }
|
|
396
|
+
catch { toast(t('toast.copyFailed'), { type: 'error' }); }
|
|
389
397
|
}
|
|
390
398
|
/** Trigger a file download via a GET endpoint (no token needed; read-only). */
|
|
391
399
|
export function downloadUrl(url) {
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
// (no horizontal scroll). Change a status inline to move an item between groups.
|
|
3
3
|
import {
|
|
4
4
|
el, get, put, navigate, EmptyState, Btn, ListRow,
|
|
5
|
-
Select, scoreClass, statusColor, mount, toast, toastOk, toastError,
|
|
5
|
+
Select, scoreClass, statusColor, mount, toast, toastOk, toastError,
|
|
6
6
|
} from '/lib.js';
|
|
7
|
+
import { t } from '/i18n.js';
|
|
7
8
|
|
|
8
|
-
// deadline(YYYY-MM-DD) → "
|
|
9
|
+
// deadline(YYYY-MM-DD) → "Due in 7d" / "Due today" / "Overdue" (null if absent/invalid)
|
|
9
10
|
function ddayLabel(deadline) {
|
|
10
11
|
if (!deadline) return null;
|
|
11
12
|
const d = new Date(`${deadline}T00:00:00`);
|
|
@@ -13,9 +14,9 @@ function ddayLabel(deadline) {
|
|
|
13
14
|
const today = new Date();
|
|
14
15
|
today.setHours(0, 0, 0, 0);
|
|
15
16
|
const days = Math.round((d.getTime() - today.getTime()) / 86400000);
|
|
16
|
-
if (days > 0) return
|
|
17
|
-
if (days === 0) return '
|
|
18
|
-
return '
|
|
17
|
+
if (days > 0) return t('applications.dday.future', { n: days });
|
|
18
|
+
if (days === 0) return t('applications.dday.today');
|
|
19
|
+
return t('applications.dday.past');
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export async function render(ctx) {
|
|
@@ -27,20 +28,15 @@ export async function render(ctx) {
|
|
|
27
28
|
if (!applications.length) {
|
|
28
29
|
mount(ctx.view, el('div', { class: 'stack-4' }, EmptyState({
|
|
29
30
|
iconName: 'layers',
|
|
30
|
-
title: '
|
|
31
|
-
body: '
|
|
32
|
-
action: Btn('
|
|
31
|
+
title: t('applications.empty.title'),
|
|
32
|
+
body: t('applications.empty.body'),
|
|
33
|
+
action: Btn(t('applications.empty.action'), { icon: 'briefcase', variant: 'primary', onClick: () => navigate('/jobs') }),
|
|
33
34
|
})));
|
|
34
35
|
return;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
// Status labels
|
|
38
|
-
const
|
|
39
|
-
for (const a of applications) if (a.status && a.status_label) labelByCode[a.status] = a.status_label;
|
|
40
|
-
let metaStatuses = [];
|
|
41
|
-
try { metaStatuses = (await meta()).statuses || []; } catch { /* labels fall back to codes */ }
|
|
42
|
-
for (const s of metaStatuses) if (!labelByCode[s.value]) labelByCode[s.value] = s.label;
|
|
43
|
-
const statusOptions = boardOrder.map((code) => ({ value: code, label: labelByCode[code] || code }));
|
|
38
|
+
// Status labels are localized client-side from the status CODE (board_order is the code list).
|
|
39
|
+
const statusOptions = boardOrder.map((code) => ({ value: code, label: t('status.' + code) }));
|
|
44
40
|
|
|
45
41
|
// Group by status.
|
|
46
42
|
const byStatus = {};
|
|
@@ -57,8 +53,8 @@ export async function render(ctx) {
|
|
|
57
53
|
return el('div', {},
|
|
58
54
|
el('div', { class: 'app-group__head' },
|
|
59
55
|
el('span', { class: 'app-group__dot', style: { background: statusColor(code) } }),
|
|
60
|
-
el('span', { class: 'app-group__title' },
|
|
61
|
-
el('span', { class: 'app-group__count tnum' },
|
|
56
|
+
el('span', { class: 'app-group__title' }, t('status.' + code)),
|
|
57
|
+
el('span', { class: 'app-group__count tnum' }, t('applications.group.count', { count: apps.length }))),
|
|
62
58
|
el('div', {}, ...apps.map(AppRow)));
|
|
63
59
|
}
|
|
64
60
|
|
|
@@ -71,7 +67,7 @@ export async function render(ctx) {
|
|
|
71
67
|
statusOptions.map((o) => ({ ...o, selected: o.value === app.status })),
|
|
72
68
|
{
|
|
73
69
|
class: 'select select--sm row-action',
|
|
74
|
-
title: '
|
|
70
|
+
title: t('applications.statusSelect.title'),
|
|
75
71
|
onClick: (e) => e.stopPropagation(),
|
|
76
72
|
onChange: (e) => { e.stopPropagation(); changeStatus(app, e.target.value, e.target); },
|
|
77
73
|
},
|
|
@@ -81,7 +77,7 @@ export async function render(ctx) {
|
|
|
81
77
|
sub: subBits.join(' · '),
|
|
82
78
|
trailing: [
|
|
83
79
|
app.fit_score != null
|
|
84
|
-
? el('span', { class: `strong tnum ${scoreClass(app.fit_score)}` },
|
|
80
|
+
? el('span', { class: `strong tnum ${scoreClass(app.fit_score)}` }, t('applications.row.score', { score: app.fit_score }))
|
|
85
81
|
: null,
|
|
86
82
|
sel,
|
|
87
83
|
],
|
|
@@ -94,8 +90,8 @@ export async function render(ctx) {
|
|
|
94
90
|
selectEl.disabled = true;
|
|
95
91
|
try {
|
|
96
92
|
const res = await put(`/api/applications/${app.job_id}/status`, { status: newValue });
|
|
97
|
-
toastOk('
|
|
98
|
-
if (res && res.hint) toast(res.hint, { title: '
|
|
93
|
+
toastOk(t('applications.toast.statusChanged'));
|
|
94
|
+
if (res && res.hint) toast(res.hint, { title: t('applications.hint.title'), type: 'default' });
|
|
99
95
|
await ctx.refreshNav();
|
|
100
96
|
await render(ctx); // refetch + re-render so the item moves to its new group
|
|
101
97
|
} catch (err) {
|