From fd288ba76e10e4295d940958fba4aeb76a46fe07 Mon Sep 17 00:00:00 2001 From: leyfung Date: Fri, 15 May 2026 14:26:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Markdown/PDF=20?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=94=AF=E6=8C=81=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Markdown 导入功能,支持 AI 智能解析简历内容 - 新增 PDF 导入功能,复用现有 AI 解析能力 - 新增 Markdown 编辑器组件,支持工具栏快捷操作(加粗、斜体、代码、列表、链接) - 新增文本域列表组件,支持多行文本输入 - 优化亮点字段,改用文本域便于输入长文本 - 更新全部 26 个模板的技能展示样式,从逗号分隔改为项目符号列表 - 更新国际化文本,支持新增导入格式 --- messages/en.json | 15 +- messages/zh.json | 15 +- src/app/api/resume/parse-markdown/route.ts | 484 ++++++++++++++++++ .../dashboard/import-json-dialog.tsx | 218 ++++++-- .../editor/fields/editable-list.tsx | 9 +- .../editor/fields/editable-markdown.tsx | 196 +++++++ .../editor/fields/editable-textarea-list.tsx | 45 ++ src/components/editor/import-dialog.tsx | 226 ++++++-- src/components/editor/sections/projects.tsx | 10 +- .../editor/sections/work-experience.tsx | 7 +- src/components/preview/templates/academic.tsx | 16 +- .../preview/templates/architect.tsx | 8 +- src/components/preview/templates/ats.tsx | 14 +- src/components/preview/templates/classic.tsx | 12 +- src/components/preview/templates/coder.tsx | 8 +- src/components/preview/templates/compact.tsx | 8 +- .../preview/templates/consultant.tsx | 8 +- .../preview/templates/corporate.tsx | 8 +- .../preview/templates/developer.tsx | 8 +- src/components/preview/templates/elegant.tsx | 8 +- src/components/preview/templates/euro.tsx | 8 +- src/components/preview/templates/finance.tsx | 8 +- src/components/preview/templates/formal.tsx | 8 +- src/components/preview/templates/japanese.tsx | 12 +- src/components/preview/templates/legal.tsx | 8 +- src/components/preview/templates/luxe.tsx | 8 +- src/components/preview/templates/magazine.tsx | 8 +- src/components/preview/templates/medical.tsx | 8 +- src/components/preview/templates/minimal.tsx | 11 +- src/components/preview/templates/nordic.tsx | 12 +- .../preview/templates/professional.tsx | 8 +- src/components/preview/templates/retro.tsx | 12 +- .../preview/templates/scientist.tsx | 8 +- src/components/preview/templates/sidebar.tsx | 8 +- src/components/preview/templates/swiss.tsx | 8 +- src/components/preview/templates/timeline.tsx | 8 +- 36 files changed, 1297 insertions(+), 169 deletions(-) create mode 100644 src/app/api/resume/parse-markdown/route.ts create mode 100644 src/components/editor/fields/editable-markdown.tsx create mode 100644 src/components/editor/fields/editable-textarea-list.tsx diff --git a/messages/en.json b/messages/en.json index 5e8a6f9..5c7af06 100644 --- a/messages/en.json +++ b/messages/en.json @@ -51,7 +51,7 @@ "templateAts": "ATS", "aiGenerate": "AI Generate", "linkedinPhoto": "LinkedIn Photo", - "importJson": "Import JSON", + "importJson": "Import", "templateAcademic": "Academic", "templateElegant": "Elegant", "templateExecutive": "Executive", @@ -484,14 +484,17 @@ }, "import": { "title": "Import Resume", - "description": "Import a previously exported JSON file to replace current content", - "dashboardDescription": "Import a previously exported JSON file to create a new resume", - "selectFile": "Click to select or drag a .json file here", - "dragHint": "Supports .json files exported from JadeAI", + "description": "Import JSON, Markdown, or PDF file to replace current content", + "dashboardDescription": "Import JSON, Markdown, or PDF file to create a new resume", + "selectFile": "Click to select or drag a file here", + "dragHint": "Supports JSON, Markdown (AI parsing), or PDF resumes", "importing": "Importing...", + "parsingMarkdown": "AI parsing Markdown...", + "parsingPdf": "AI parsing PDF...", "success": "Import successful!", "error": "Import failed. Please try again.", - "invalidFormat": "Invalid file format. Please select a valid JSON file with sections data.", + "invalidFormat": "Invalid file format. Please select a .json, .md, or .pdf file.", + "noApiKey": "Please configure AI API Key in Settings first.", "importBtn": "Import", "cancel": "Cancel" }, diff --git a/messages/zh.json b/messages/zh.json index b6ff0d6..e8110d2 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -51,7 +51,7 @@ "templateAts": "ATS", "aiGenerate": "AI 生成", "linkedinPhoto": "领英照", - "importJson": "导入 JSON", + "importJson": "导入", "templateAcademic": "学术", "templateElegant": "优雅", "templateExecutive": "高管", @@ -484,14 +484,17 @@ }, "import": { "title": "导入简历", - "description": "导入之前导出的 JSON 文件,替换当前简历内容", - "dashboardDescription": "导入之前导出的 JSON 文件,创建新简历", - "selectFile": "点击选择或拖拽 .json 文件到此处", - "dragHint": "支持从 JadeAI 导出的 .json 文件", + "description": "导入 JSON、Markdown 或 PDF 文件,替换当前简历内容", + "dashboardDescription": "导入 JSON、Markdown 或 PDF 文件,创建新简历", + "selectFile": "点击选择或拖拽文件到此处", + "dragHint": "支持 JSON、Markdown(AI 解析)或 PDF 简历", "importing": "导入中...", + "parsingMarkdown": "AI 解析 Markdown 中...", + "parsingPdf": "AI 解析 PDF 中...", "success": "导入成功!", "error": "导入失败,请重试。", - "invalidFormat": "文件格式无效,请选择包含 sections 数据的有效 JSON 文件。", + "invalidFormat": "文件格式无效,请选择 .json、.md 或 .pdf 文件。", + "noApiKey": "请先在设置中配置 AI API Key。", "importBtn": "导入", "cancel": "取消" }, diff --git a/src/app/api/resume/parse-markdown/route.ts b/src/app/api/resume/parse-markdown/route.ts new file mode 100644 index 0000000..a2f3ed7 --- /dev/null +++ b/src/app/api/resume/parse-markdown/route.ts @@ -0,0 +1,484 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { generateText } from 'ai'; +import { getModel, extractAIConfig, getJsonProviderOptions, AIConfigError } from '@/lib/ai/provider'; +import { resolveUser, getUserIdFromRequest } from '@/lib/auth/helpers'; +import { resumeRepository } from '@/lib/db/repositories/resume.repository'; +import type { ParsedResume } from '@/lib/ai/parse-schema'; + +const SYSTEM_PROMPT = `You are a resume parser. Extract ALL information from the Markdown resume into the EXACT JSON schema below. + +REQUIRED JSON SCHEMA: +{"personalInfo":{"fullName":"","jobTitle":"","age":"","gender":"","politicalStatus":"","ethnicity":"","hometown":"","maritalStatus":"","yearsOfExperience":"","educationLevel":"","email":"","phone":"","wechat":"","location":"","website":"","linkedin":"","github":""},"summary":"","workExperience":[{"company":"Company A","position":"","location":"","startDate":"YYYY-MM","endDate":"YYYY-MM or null","current":false,"description":"","highlights":["bullet 1","bullet 2"]},{"company":"Company B","position":"","location":"","startDate":"YYYY-MM","endDate":"YYYY-MM","current":false,"description":"","highlights":[]}],"education":[{"institution":"University A","degree":"","field":"","location":"","startDate":"YYYY-MM","endDate":"YYYY-MM","gpa":"","highlights":[]},{"institution":"University B","degree":"","field":"","location":"","startDate":"YYYY-MM","endDate":"YYYY-MM","gpa":"","highlights":[]}],"skills":[{"name":"category name","skills":["skill1","skill2"]}],"projects":[{"name":"Project A","description":"","technologies":[],"highlights":[]},{"name":"Project B","description":"","technologies":[],"highlights":[]}],"certifications":[{"name":"","issuer":"","date":""}],"languages":[{"language":"","proficiency":""}]} + +RULES: +- You MUST use the EXACT field names shown above (fullName, jobTitle, workExperience, etc.) +- Output compact single-line JSON. No indentation, no newlines. +- You are a JSON API. Your entire response must be a single valid JSON object starting with { and ending with }. Do NOT use markdown syntax. Do NOT wrap in code fences. Do NOT add any text before or after the JSON. +- Use YYYY-MM for dates. Empty string "" for missing fields. +- For current jobs: current=true, endDate=null. +- Omit empty arrays (e.g. if no projects, omit "projects" entirely). +- Extract ALL items for EVERY section — every work experience, every project, every education entry, every certification, every language. If the resume has 3 projects, return 3 objects in the projects array. If the resume has 5 work experiences, return 5 objects in the workExperience array. +- Parse Markdown formatting carefully. List items (- or *) under work experience should become highlights. Bold text (**text**) indicates emphasis. +- Section headers in Markdown (## or ###) indicate different resume sections.`; + +export async function POST(request: NextRequest) { + try { + const fingerprint = getUserIdFromRequest(request); + const user = await resolveUser(fingerprint); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { content, template, language } = body; + + if (!content || typeof content !== 'string') { + return NextResponse.json({ error: 'Markdown content is required' }, { status: 400 }); + } + + if (content.trim().length < 50) { + return NextResponse.json({ error: 'Content is too short to be a valid resume' }, { status: 400 }); + } + + const aiConfig = extractAIConfig(request); + const model = getModel(aiConfig); + + // Use AI to parse Markdown content + const result = await generateText({ + model, + maxOutputTokens: 16384, + system: SYSTEM_PROMPT, + messages: [ + { + role: 'user', + content: `Below is a resume written in Markdown format. Extract all resume information using the EXACT JSON schema from the system prompt.\n\n---\n${content}\n---`, + }, + ], + providerOptions: getJsonProviderOptions(aiConfig), + }); + + console.log('[parse-markdown] finishReason=%s, length=%d', result.finishReason, result.text.length); + + // Parse JSON from response + const raw = parseJsonFromText(result.text); + if (!raw || typeof raw !== 'object') { + console.error('[parse-markdown] Failed to parse JSON. Raw text:', result.text.slice(0, 500)); + return NextResponse.json({ error: 'Failed to extract resume data from Markdown' }, { status: 500 }); + } + + // Map to our schema + const resumeData = mapToResumeSchema(raw as Record); + + // Create resume with parsed data + const resume = await resumeRepository.create({ + userId: user.id, + title: resumeData.personalInfo?.fullName || '未命名简历', + template: template || 'classic', + language: language || 'zh', + }); + + if (!resume) { + return NextResponse.json({ error: 'Failed to create resume' }, { status: 500 }); + } + + // Create sections from parsed data + const sections = buildSections(resumeData, language || 'zh'); + for (let i = 0; i < sections.length; i++) { + await resumeRepository.createSection({ + resumeId: resume.id, + type: sections[i].type, + title: sections[i].title, + sortOrder: i, + content: sections[i].content, + }); + } + + const fullResume = await resumeRepository.findById(resume.id); + return NextResponse.json(fullResume, { status: 201 }); + } catch (error) { + if (error instanceof AIConfigError) { + return NextResponse.json({ error: error.message }, { status: 401 }); + } + console.error('POST /api/resume/parse-markdown error:', error); + return NextResponse.json({ error: 'Failed to parse Markdown resume' }, { status: 500 }); + } +} + +// ─── JSON Parsing ──────────────────────────────────────────────────────────── + +function parseJsonFromText(text: string): unknown | null { + let cleaned = text.trim(); + + // Strip markdown code fences + cleaned = cleaned.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?\s*```\s*$/, ''); + cleaned = cleaned.trim(); + + // Try candidates in order + const candidates: string[] = [cleaned]; + + const start = cleaned.indexOf('{'); + const end = cleaned.lastIndexOf('}'); + if (start !== -1 && end > start) { + candidates.push(cleaned.slice(start, end + 1)); + } + + for (const c of candidates) { + try { + return JSON.parse(c); + } catch (e) { + // Log first attempt error for diagnostics + if (c === candidates[0]) { + console.warn('[parse-markdown] JSON.parse error:', (e as Error).message?.slice(0, 100)); + } + // Try repair for truncated JSON + const repaired = repairTruncatedJson(c); + if (repaired) { + try { + return JSON.parse(repaired); + } catch { + /* continue */ + } + } + } + } + + return null; +} + +function repairTruncatedJson(text: string): string | null { + let s = text.trim(); + if (!s.startsWith('{') && !s.startsWith('[')) return null; + + s = s.replace(/,\s*$/, ''); + + // Remove trailing incomplete key-value pair + s = s.replace(/,\s*"[^"]*"\s*:\s*"[^"]*$/, ''); + if (s.match(/:\s*"[^"]*$/)) s += '"'; + s = s.replace(/,\s*"[^"]*"?\s*:?\s*$/, ''); + s = s.replace(/,\s*$/, ''); + + const stack: string[] = []; + let inString = false; + let escaped = false; + + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\' && inString) { + escaped = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (ch === '{') stack.push('}'); + else if (ch === '[') stack.push(']'); + else if ((ch === '}' || ch === ']') && stack.length > 0) stack.pop(); + } + + if (inString) s += '"'; + while (stack.length > 0) s += stack.pop()!; + + return s; +} + +// ─── Flexible Schema Mapping ───────────────────────────────────────────────── + +function str(v: unknown): string { + if (v === null || v === undefined) return ''; + return String(v); +} + +function mapArray(raw: unknown, mapper: (item: Record) => T): T[] { + if (!Array.isArray(raw)) return []; + return raw.map((item) => mapper(item as Record)); +} + +function toStringArray(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw.map((v) => String(v)).filter(Boolean); +} + +function mapSkills(raw: unknown): { name: string; skills: string[] }[] { + if (!raw) return []; + + if (Array.isArray(raw)) { + if (raw.length === 0) return []; + + if (typeof raw[0] === 'object' && raw[0] !== null) { + return raw + .map((s: Record) => ({ + name: str(s.name || s.category || s.type || s.group || 'Skills'), + skills: toStringArray(s.skills || s.items || s.list || s.keywords || []), + })) + .filter((s) => s.skills.length > 0); + } + + if (typeof raw[0] === 'string') { + return [{ name: 'Skills', skills: raw.map(String) }]; + } + } + + if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) { + return Object.entries(raw as Record) + .filter(([, v]) => Array.isArray(v)) + .map(([k, v]) => ({ name: k, skills: (v as unknown[]).map(String) })); + } + + return []; +} + +/** + * Map any model-returned JSON to our ParsedResume schema. + */ +function mapToResumeSchema(raw: Record): ParsedResume { + const pi = (raw.personalInfo || raw.personal_info || raw.basicInfo || raw.basic_info || {}) as Record; + const ji = (raw.jobIntention || raw.job_intention || {}) as Record; + + const personalInfo = { + fullName: str(pi.fullName || pi.name || pi.姓名 || ''), + jobTitle: str(pi.jobTitle || pi.title || pi.position || ji.position || ji.jobTitle || pi.职位 || ''), + age: str(pi.age || pi.年龄 || ''), + gender: str(pi.gender || pi.sex || pi.性别 || ''), + politicalStatus: str(pi.politicalStatus || pi.political_status || pi.政治面貌 || ''), + ethnicity: str(pi.ethnicity || pi.nationality || pi.民族 || ''), + hometown: str(pi.hometown || pi.nativePlace || pi.native_place || pi.籍贯 || ''), + maritalStatus: str(pi.maritalStatus || pi.marital_status || pi.婚姻状况 || pi.婚姻 || ''), + yearsOfExperience: str(pi.yearsOfExperience || pi.years_of_experience || pi.experience || pi.工作年限 || pi.工作经验 || ''), + educationLevel: str(pi.educationLevel || pi.education_level || pi.education || pi.最高学历 || pi.学历 || ''), + email: str(pi.email || pi.邮箱 || ''), + phone: str(pi.phone || pi.tel || pi.mobile || pi.电话 || pi.手机 || ''), + wechat: str(pi.wechat || pi.weixin || pi.微信 || ''), + location: str(pi.location || pi.city || pi.address || ji.city || pi.地址 || pi.城市 || ''), + website: str(pi.website || pi.url || pi.homepage || ''), + linkedin: str(pi.linkedin || ''), + github: str(pi.github || ''), + }; + + const summary = str(raw.summary || raw.objective || raw.selfIntroduction || raw.selfEvaluation || raw.profile || raw.about || ''); + + const workExperience = mapArray( + raw.workExperience || raw.work_experience || raw.experience || raw.work || [], + (w: Record) => ({ + company: str(w.company || w.companyName || w.employer || ''), + position: str(w.position || w.title || w.jobTitle || w.role || ''), + location: str(w.location || w.city || ''), + startDate: str(w.startDate || w.start_date || w.startTime || ''), + endDate: + w.endDate === null || w.end_date === null || str(w.endDate || w.end_date || w.endTime || '') === '至今' + ? null + : str(w.endDate || w.end_date || w.endTime || ''), + current: Boolean(w.current || w.isCurrent || str(w.endDate || w.end_date || '') === '至今'), + description: str(w.description || w.desc || w.content || ''), + highlights: toStringArray(w.highlights || w.achievements || w.bullets || w.duties || []), + }) + ); + + const education = mapArray( + raw.education || raw.edu || [], + (e: Record) => ({ + institution: str(e.institution || e.school || e.university || e.college || e.schoolName || ''), + degree: str(e.degree || e.学历 || ''), + field: str(e.field || e.major || e.专业 || ''), + location: str(e.location || ''), + startDate: str(e.startDate || e.start_date || e.startTime || ''), + endDate: str(e.endDate || e.end_date || e.endTime || ''), + gpa: str(e.gpa || e.GPA || ''), + highlights: toStringArray(e.highlights || e.achievements || e.courses || []), + }) + ); + + const skills = mapSkills(raw.skills || raw.skill || []); + + const projects = mapArray( + raw.projects || raw.project || [], + (p: Record) => ({ + name: str(p.name || p.projectName || p.title || ''), + url: str(p.url || p.link || ''), + startDate: str(p.startDate || p.start_date || ''), + endDate: str(p.endDate || p.end_date || ''), + description: str(p.description || p.desc || p.content || ''), + technologies: toStringArray(p.technologies || p.tech || p.techStack || p.skills || []), + highlights: toStringArray(p.highlights || p.achievements || []), + }) + ); + + const certifications = mapArray( + raw.certifications || raw.certificates || raw.certs || [], + (c: Record) => ({ + name: str(c.name || c.title || ''), + issuer: str(c.issuer || c.organization || c.org || ''), + date: str(c.date || c.issueDate || ''), + url: str(c.url || ''), + }) + ); + + const languages = mapArray( + raw.languages || raw.language || [], + (l: Record) => ({ + language: str(l.language || l.name || ''), + proficiency: str(l.proficiency || l.level || ''), + }) + ); + + return { + personalInfo, + ...(summary ? { summary } : {}), + ...(workExperience.length ? { workExperience } : {}), + ...(education.length ? { education } : {}), + ...(skills.length ? { skills } : {}), + ...(projects.length ? { projects } : {}), + ...(certifications.length ? { certifications } : {}), + ...(languages.length ? { languages } : {}), + }; +} + +// ─── Build Sections ────────────────────────────────────────────────────────── + +function buildSections(parsed: ParsedResume, language: string) { + const isEn = language === 'en'; + const sections: { type: string; title: string; content: unknown }[] = []; + + sections.push({ + type: 'personal_info', + title: isEn ? 'Personal Info' : '个人信息', + content: { + fullName: parsed.personalInfo?.fullName || '', + jobTitle: parsed.personalInfo?.jobTitle || '', + age: parsed.personalInfo?.age || '', + gender: parsed.personalInfo?.gender || '', + politicalStatus: parsed.personalInfo?.politicalStatus || '', + ethnicity: parsed.personalInfo?.ethnicity || '', + hometown: parsed.personalInfo?.hometown || '', + maritalStatus: parsed.personalInfo?.maritalStatus || '', + yearsOfExperience: parsed.personalInfo?.yearsOfExperience || '', + educationLevel: parsed.personalInfo?.educationLevel || '', + email: parsed.personalInfo?.email || '', + phone: parsed.personalInfo?.phone || '', + wechat: parsed.personalInfo?.wechat || '', + location: parsed.personalInfo?.location || '', + website: parsed.personalInfo?.website || '', + linkedin: parsed.personalInfo?.linkedin || '', + github: parsed.personalInfo?.github || '', + }, + }); + + if (parsed.summary) { + sections.push({ + type: 'summary', + title: isEn ? 'Summary' : '个人简介', + content: { text: parsed.summary }, + }); + } + + if (parsed.workExperience?.length) { + sections.push({ + type: 'work_experience', + title: isEn ? 'Work Experience' : '工作经历', + content: { + items: parsed.workExperience.map((w) => ({ + id: crypto.randomUUID(), + company: w.company, + position: w.position, + location: w.location || '', + startDate: w.startDate, + endDate: w.endDate, + current: w.current, + description: w.description, + technologies: [], + highlights: w.highlights, + })), + }, + }); + } + + if (parsed.education?.length) { + sections.push({ + type: 'education', + title: isEn ? 'Education' : '教育背景', + content: { + items: parsed.education.map((e) => ({ + id: crypto.randomUUID(), + institution: e.institution, + degree: e.degree, + field: e.field, + location: e.location || '', + startDate: e.startDate, + endDate: e.endDate, + gpa: e.gpa || '', + highlights: e.highlights, + })), + }, + }); + } + + if (parsed.skills?.length) { + sections.push({ + type: 'skills', + title: isEn ? 'Skills' : '技能特长', + content: { + categories: parsed.skills.map((s) => ({ + id: crypto.randomUUID(), + name: s.name, + skills: s.skills, + })), + }, + }); + } + + if (parsed.projects?.length) { + sections.push({ + type: 'projects', + title: isEn ? 'Projects' : '项目经历', + content: { + items: parsed.projects.map((p) => ({ + id: crypto.randomUUID(), + name: p.name, + url: p.url || '', + startDate: p.startDate || '', + endDate: p.endDate || '', + description: p.description, + technologies: p.technologies, + highlights: p.highlights, + })), + }, + }); + } + + if (parsed.certifications?.length) { + sections.push({ + type: 'certifications', + title: isEn ? 'Certifications' : '资格证书', + content: { + items: parsed.certifications.map((c) => ({ + id: crypto.randomUUID(), + name: c.name, + issuer: c.issuer, + date: c.date, + url: c.url || '', + })), + }, + }); + } + + if (parsed.languages?.length) { + sections.push({ + type: 'languages', + title: isEn ? 'Languages' : '语言能力', + content: { + items: parsed.languages.map((l) => ({ + id: crypto.randomUUID(), + language: l.language, + proficiency: l.proficiency, + })), + }, + }); + } + + return sections; +} diff --git a/src/components/dashboard/import-json-dialog.tsx b/src/components/dashboard/import-json-dialog.tsx index d9b36f8..42387b5 100644 --- a/src/components/dashboard/import-json-dialog.tsx +++ b/src/components/dashboard/import-json-dialog.tsx @@ -12,12 +12,15 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; +import { getAIHeaders } from '@/stores/settings-store'; import { Upload, Loader2, CheckCircle2, AlertCircle, FileJson, + FileText, + FileIcon, } from 'lucide-react'; interface ImportJsonDialogProps { @@ -26,8 +29,16 @@ interface ImportJsonDialogProps { } type ImportState = 'idle' | 'importing' | 'success' | 'error'; +type FileType = 'json' | 'markdown' | 'pdf'; function getHeaders() { + const fingerprint = typeof window !== 'undefined' ? localStorage.getItem('jade_fingerprint') : null; + const headers: Record = {}; + if (fingerprint) headers['x-fingerprint'] = fingerprint; + return headers; +} + +function getJsonHeaders() { const fingerprint = typeof window !== 'undefined' ? localStorage.getItem('jade_fingerprint') : null; return { 'Content-Type': 'application/json', @@ -35,6 +46,18 @@ function getHeaders() { }; } +function getFileType(file: File): FileType { + if (file.name.endsWith('.json')) return 'json'; + if (file.name.endsWith('.md') || file.name.endsWith('.markdown')) return 'markdown'; + if (file.name.endsWith('.pdf')) return 'pdf'; + return 'json'; +} + +function isSupportedFile(file: File): boolean { + const ext = file.name.toLowerCase(); + return ext.endsWith('.json') || ext.endsWith('.md') || ext.endsWith('.markdown') || ext.endsWith('.pdf'); +} + export function ImportJsonDialog({ open, onOpenChange }: ImportJsonDialogProps) { const t = useTranslations('import'); const router = useRouter(); @@ -42,6 +65,7 @@ export function ImportJsonDialog({ open, onOpenChange }: ImportJsonDialogProps) const [state, setState] = useState('idle'); const [errorMessage, setErrorMessage] = useState(''); const [selectedFile, setSelectedFile] = useState(null); + const [fileType, setFileType] = useState('json'); const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); @@ -50,16 +74,19 @@ export function ImportJsonDialog({ open, onOpenChange }: ImportJsonDialogProps) setState('idle'); setErrorMessage(''); setSelectedFile(null); + setFileType('json'); } }, [open]); const handleFileSelect = useCallback((file: File) => { - if (!file.name.endsWith('.json')) { + if (!isSupportedFile(file)) { setState('error'); setErrorMessage(t('invalidFormat')); + setSelectedFile(null); return; } setSelectedFile(file); + setFileType(getFileType(file)); setState('idle'); setErrorMessage(''); }, [t]); @@ -93,33 +120,102 @@ export function ImportJsonDialog({ open, onOpenChange }: ImportJsonDialogProps) setErrorMessage(''); try { - const text = await selectedFile.text(); - const data = JSON.parse(text); + if (fileType === 'pdf') { + // PDF import: use existing parse API with FormData + const aiHeaders = getAIHeaders(); + if (!aiHeaders['x-api-key']) { + setState('error'); + setErrorMessage(t('noApiKey')); + return; + } - if (!Array.isArray(data.sections)) { - throw new Error(t('invalidFormat')); - } + const formData = new FormData(); + formData.append('file', selectedFile); + + const res = await fetch('/api/resume/parse', { + method: 'POST', + headers: { ...getHeaders(), ...aiHeaders }, + body: formData, + }); + + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + setState('error'); + setErrorMessage(errData.error || t('error')); + return; + } + const newResume = await res.json(); + + setState('success'); + setTimeout(() => { + onOpenChange(false); + router.push(`/editor/${newResume.id}`); + }, 1000); + } else if (fileType === 'markdown') { + // Markdown import: use AI to parse + const aiHeaders = getAIHeaders(); + if (!aiHeaders['x-api-key']) { + setState('error'); + setErrorMessage(t('noApiKey')); + return; + } + + const text = await selectedFile.text(); + const res = await fetch('/api/resume/parse-markdown', { + method: 'POST', + headers: { ...getJsonHeaders(), ...aiHeaders }, + body: JSON.stringify({ content: text }), + }); + + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + setState('error'); + setErrorMessage(errData.error || t('error')); + return; + } + const newResume = await res.json(); + + setState('success'); + setTimeout(() => { + onOpenChange(false); + router.push(`/editor/${newResume.id}`); + }, 1000); + } else { + // JSON import: existing logic + const text = await selectedFile.text(); + const data = JSON.parse(text); - // Create a new resume with imported data (ids are ignored server-side) - const res = await fetch('/api/resume', { - method: 'POST', - headers: getHeaders(), - body: JSON.stringify({ - title: data.title || 'Imported Resume', - template: data.template || 'classic', - themeConfig: data.themeConfig, - sections: data.sections, - }), - }); - - if (!res.ok) throw new Error(t('error')); - const newResume = await res.json(); - - setState('success'); - setTimeout(() => { - onOpenChange(false); - router.push(`/editor/${newResume.id}`); - }, 1000); + if (!Array.isArray(data.sections)) { + setState('error'); + setErrorMessage(t('invalidFormat')); + return; + } + + // Create a new resume with imported data (ids are ignored server-side) + const res = await fetch('/api/resume', { + method: 'POST', + headers: getJsonHeaders(), + body: JSON.stringify({ + title: data.title || 'Imported Resume', + template: data.template || 'classic', + themeConfig: data.themeConfig, + sections: data.sections, + }), + }); + + if (!res.ok) { + setState('error'); + setErrorMessage(t('error')); + return; + } + const newResume = await res.json(); + + setState('success'); + setTimeout(() => { + onOpenChange(false); + router.push(`/editor/${newResume.id}`); + }, 1000); + } } catch (err: any) { setState('error'); if (err instanceof SyntaxError) { @@ -128,13 +224,25 @@ export function ImportJsonDialog({ open, onOpenChange }: ImportJsonDialogProps) setErrorMessage(err.message || t('error')); } } - }, [selectedFile, onOpenChange, router, t]); + }, [selectedFile, fileType, onOpenChange, router, t]); const isLoading = state === 'importing'; + const renderFileIcon = () => { + if (fileType === 'pdf') return ; + if (fileType === 'markdown') return ; + return ; + }; + + const getLoadingText = () => { + if (fileType === 'pdf') return t('parsingPdf'); + if (fileType === 'markdown') return t('parsingMarkdown'); + return t('importing'); + }; + return ( { if (!o && !isLoading) onOpenChange(false); }}> - + { if (isLoading) e.preventDefault(); }}> @@ -144,7 +252,19 @@ export function ImportJsonDialog({ open, onOpenChange }: ImportJsonDialogProps)
- {(state === 'idle' || (state === 'error' && selectedFile)) && ( + {(state === 'idle' || state === 'error') && selectedFile && ( +
+ {renderFileIcon()} +

+ {selectedFile.name} +

+

{t('dragHint')}

+
+ )} + + {(state === 'idle' || state === 'error') && !selectedFile && (
- {selectedFile ? ( - <> - -

- {selectedFile.name} -

-

{t('dragHint')}

- - ) : ( - <> - -

- {t('selectFile')} -

-

{t('dragHint')}

- - )} + +

+ {t('selectFile')} +

+

{t('dragHint')}

)} - {state === 'error' && !selectedFile && ( -
- -

- {errorMessage || t('error')} + {state === 'error' && errorMessage && ( +

+ +

+ {errorMessage}

)} @@ -198,7 +304,7 @@ export function ImportJsonDialog({ open, onOpenChange }: ImportJsonDialogProps)

- {t('importing')} + {getLoadingText()}

)} diff --git a/src/components/editor/fields/editable-list.tsx b/src/components/editor/fields/editable-list.tsx index af0c82a..afabf09 100644 --- a/src/components/editor/fields/editable-list.tsx +++ b/src/components/editor/fields/editable-list.tsx @@ -1,7 +1,7 @@ 'use client'; import { Plus, X } from 'lucide-react'; -import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; interface EditableListProps { @@ -29,12 +29,13 @@ export function EditableList({ label, items, onChange, placeholder }: EditableLi
{(items || []).map((item, index) => ( -
- +