From accd34d599ba04866df14b1aff9cff6dcc72676f Mon Sep 17 00:00:00 2001 From: JOYCEQL <1449239013@qq.com> Date: Sat, 28 Feb 2026 21:48:52 +0800 Subject: [PATCH] feat: implement resume creation modal with template selection and preview functionality --- .../dashboard/resumes/CreateResumeModal.tsx | 371 ++++++++++++++++++ src/app/app/dashboard/resumes/page.tsx | 56 ++- src/components/templates/classic/config.ts | 2 +- src/components/ui/dialog.tsx | 18 +- src/i18n/locales/en.json | 29 +- src/i18n/locales/zh.json | 29 +- 6 files changed, 487 insertions(+), 18 deletions(-) create mode 100644 src/app/app/dashboard/resumes/CreateResumeModal.tsx diff --git a/src/app/app/dashboard/resumes/CreateResumeModal.tsx b/src/app/app/dashboard/resumes/CreateResumeModal.tsx new file mode 100644 index 0000000..b74f5fd --- /dev/null +++ b/src/app/app/dashboard/resumes/CreateResumeModal.tsx @@ -0,0 +1,371 @@ +import React, { useEffect, useRef, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { useTranslations } from "@/i18n/compat/client"; +import { + Dialog, + DialogContent, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { DEFAULT_TEMPLATES } from "@/config"; +import { initialResumeState } from "@/config/initialResumeData"; +import ResumeTemplateComponent from "@/components/templates"; +import type { Translator } from "@/i18n/compat/utils"; +import type { ResumeData } from "@/types/resume"; +import type { ResumeTemplate } from "@/types/template"; +import { ChevronLeft, FilePlus, Sparkles, X } from "lucide-react"; + +interface CreateResumeModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreate: (templateId: string | null) => void; +} + +const A4_WIDTH_PX = 793.700787; +const A4_HEIGHT_PX = 1122.519685; + +type BlankTemplate = { + id: null; + isBlank: true; + nameKey: "blankTitle"; +}; + +type NormalTemplate = ResumeTemplate & { isBlank: false; nameKey: string }; +type TemplateOption = NormalTemplate | BlankTemplate; + +const toTemplateNameKey = (templateId: string) => + templateId === "left-right" ? "leftRight" : templateId; + +const BLANK_TEMPLATE: BlankTemplate = { id: null, isBlank: true, nameKey: "blankTitle" }; +const NORMAL_TEMPLATES: NormalTemplate[] = DEFAULT_TEMPLATES.map((template) => ({ + ...template, + isBlank: false, + nameKey: toTemplateNameKey(template.id), +})); + +const TemplateThumbnail = ({ + template, + t, + scaleModifier = 1, + quality = "low" // low for grid, high for preview +}: { + template: TemplateOption, + t: Translator, + scaleModifier?: number, + quality?: "low" | "high" +}) => { + const containerRef = useRef(null); + const [scale, setScale] = useState(0.2); + + useEffect(() => { + if (!containerRef.current || template.isBlank) return; + const observer = new ResizeObserver((entries) => { + const { width } = entries[0].contentRect; + if (width > 0) { + setScale((width / A4_WIDTH_PX) * scaleModifier); // Exact 210mm in pixels at 96dpi + } + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, [template.isBlank, scaleModifier]); + + if (template.isBlank) { + return ( +
+
+ +
+ + {t("dashboard.resumes.createDialog.blankTitle")} + +

+ {t("dashboard.resumes.createDialog.blankThumbnailDescription")} +

+
+ ); + } + + const sampleExperience = quality === "high" + ? [ + { + id: "1", + company: t("dashboard.resumes.createDialog.sample.company"), + position: t("dashboard.resumes.createDialog.sample.position"), + date: `2020-01 - ${t("dashboard.resumes.createDialog.sample.present")}`, + details: t("dashboard.resumes.createDialog.sample.workDescription"), + visible: true, + }, + ] + : []; + + const previewData: ResumeData = { + ...initialResumeState, + id: "preview-mock", + templateId: template.id, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + globalSettings: { + ...initialResumeState.globalSettings, + themeColor: template.colorScheme?.primary || "#000", + sectionSpacing: template.spacing?.sectionGap || 16, + paragraphSpacing: template.spacing?.itemGap || 8, + pagePadding: template.spacing?.contentPadding || 32, + }, + basic: { + ...initialResumeState.basic, + layout: template.basic?.layout || "classic", + }, + // Feed richer mock content in large preview. + experience: sampleExperience, + }; + + return ( +
+ {/* Wrapper to hold the exact scaled dimensions so flexbox layout is preserved without absolute positioning */} +
+
+ +
+
+
+ ); +}; + +export const CreateResumeModal = ({ + open, + onOpenChange, + onCreate, +}: CreateResumeModalProps) => { + const t = useTranslations(); + const [previewTarget, setPreviewTarget] = useState(null); + + const handleCreate = (template: TemplateOption) => { + onCreate(template.id); + setPreviewTarget(null); + }; + + // Close preview when dialog closes + useEffect(() => { + if (!open) { + const timeoutId = window.setTimeout(() => setPreviewTarget(null), 300); + return () => window.clearTimeout(timeoutId); + } + }, [open]); + + return ( + + + {/* We keep an empty DialogTitle to satisfy accessibility requirements without taking up space */} + {t("dashboard.resumes.createDialog.title")} + +
+ {/* HEADER BAR */} +
+
+ {t("dashboard.resumes.createDialog.title")} +
+ +
+ +
+ +
+ {/* SECTION 1: BLANK TEMPLATE */} +
+
+

+ {t("dashboard.resumes.createDialog.startFromBlank")} +

+
+
+ handleCreate(BLANK_TEMPLATE)} + className="group cursor-pointer rounded-2xl border border-gray-200/60 dark:border-gray-800/60 shadow-sm bg-gray-50/50 dark:bg-gray-900/50 hover:bg-white dark:hover:bg-gray-900 hover:shadow-xl hover:border-primary/50 dark:hover:border-primary/50 transition-all duration-300 p-6 flex flex-col sm:flex-row items-center gap-6" + > + {/* Small visual icon area */} + + + + +
+ +
+ {t("dashboard.resumes.createDialog.blankTitle")} +
+
+

+ {t("dashboard.resumes.createDialog.blankCardDescription")} +

+
+ +
+ {t("dashboard.resumes.createDialog.createNow")} +
+
+
+ + {/* SECTION 2: NORMAL TEMPLATES */} +
+
+

+ {t("dashboard.resumes.createDialog.startFromTemplate")} +

+
+
+
+ {NORMAL_TEMPLATES.map((template) => { + const templateName = t(`dashboard.templates.${template.nameKey}.name`); + + return ( + setPreviewTarget(template)} + className="group cursor-pointer flex flex-col" + > + {/* The Thumbnail Card */} + + +
+
+ + + {/* Minimalist Title below */} + + + {templateName} + + + + ) + })} +
+
+
+
+
+ + {/* OVERLAY LARGE PREVIEW (Shared Layout Animation) */} + + {previewTarget && ( + + {/* Left: Huge Preview Area */} +
+
+ +
+ + + + + + +
+ + {/* Right: Info Sidebar */} +
+
+ +

+ {previewTarget.isBlank + ? t("dashboard.resumes.createDialog.blankTitle") + : t(`dashboard.templates.${previewTarget.nameKey}.name`)} +

+
+ +
+ +

+ {previewTarget.isBlank + ? t("dashboard.resumes.createDialog.blankPreviewDescription") + : t(`dashboard.templates.${previewTarget.nameKey}.description`)} +

+ +
+ +
+
+
+ + )} + +
+ +
+ ); +}; diff --git a/src/app/app/dashboard/resumes/page.tsx b/src/app/app/dashboard/resumes/page.tsx index 55cf089..dbe6ece 100644 --- a/src/app/app/dashboard/resumes/page.tsx +++ b/src/app/app/dashboard/resumes/page.tsx @@ -33,6 +33,7 @@ import ResumeTemplateComponent from "@/components/templates"; import { DEFAULT_TEMPLATES } from "@/config"; import { generateUUID } from "@/utils/uuid"; +import { CreateResumeModal } from "./CreateResumeModal"; const ResumesList = () => { return ; @@ -41,6 +42,11 @@ const ResumesList = () => { const ResumeCardItem = ({ id, resume, t, locale, setActiveResume, router, deleteResume, index }: any) => { const containerRef = React.useRef(null); const [scale, setScale] = React.useState(0.24); + const activeTemplate = + DEFAULT_TEMPLATES.find((template) => template.id === resume.templateId) ?? + DEFAULT_TEMPLATES[0]; + const templateNameKey = + activeTemplate.id === "left-right" ? "leftRight" : activeTemplate.id; React.useEffect(() => { if (!containerRef.current) return; @@ -87,10 +93,7 @@ const ResumeCardItem = ({ id, resume, t, locale, setActiveResume, router, delete fontFamily: resume.globalSettings?.fontFamily || "Alibaba PuHuiTi, sans-serif", }} > - {(() => { - const template = DEFAULT_TEMPLATES.find(t => t.id === resume.templateId) || DEFAULT_TEMPLATES[0]; - return ; - })()} + @@ -102,7 +105,7 @@ const ResumeCardItem = ({ id, resume, t, locale, setActiveResume, router, delete {resume.title || t("dashboard.resumes.untitled")} - {t(`dashboard.templates.${DEFAULT_TEMPLATES.find(t => t.id === resume.templateId)?.id === "left-right" ? "leftRight" : (DEFAULT_TEMPLATES.find(t => t.id === resume.templateId)?.id || "classic")}.name`)} · {new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(resume.updatedAt || resume.createdAt))} + {t(`dashboard.templates.${templateNameKey}.name`)} · {new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(resume.updatedAt || resume.createdAt))} @@ -198,6 +201,7 @@ const ResumeWorkbench = () => { } = useResumeStore(); const router = useRouter(); const [hasConfiguredFolder, setHasConfiguredFolder] = React.useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = React.useState(false); useEffect(() => { const syncResumesFromFiles = async () => { @@ -248,11 +252,39 @@ const ResumeWorkbench = () => { loadSavedConfig(); }, []); - const handleCreateResume = () => { - const newId = createResume(null); + const handleCreateFromModal = (templateId: string | null) => { + const newId = createResume(templateId); + + if (templateId) { + const template = DEFAULT_TEMPLATES.find((t) => t.id === templateId); + if (template) { + const { resumes, updateResume } = useResumeStore.getState(); + const resume = resumes[newId]; + if (resume) { + updateResume(newId, { + globalSettings: { + ...resume.globalSettings, + themeColor: template.colorScheme.primary, + sectionSpacing: template.spacing.sectionGap, + paragraphSpacing: template.spacing.itemGap, + pagePadding: template.spacing.contentPadding, + }, + basic: { + ...resume.basic, + layout: template.basic.layout, + }, + }); + } + } + } + + setIsCreateModalOpen(false); setActiveResume(newId); + router.push(`/app/workbench/${newId}`); }; + + const handleImportJson = () => { const input = document.createElement("input"); input.type = "file"; @@ -376,7 +408,7 @@ const ResumeWorkbench = () => { transition={{ type: "spring", stiffness: 400, damping: 17 }} >