diff --git a/src/components/preview/BaseInfo.tsx b/src/components/preview/BaseInfo.tsx deleted file mode 100644 index ccfbc31..0000000 --- a/src/components/preview/BaseInfo.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import React from "react"; -import { useTranslations, useLocale } from "@/i18n/compat/client"; -import { motion } from "framer-motion"; -import * as Icons from "lucide-react"; -import { cn, formatDateString } from "@/lib/utils"; -import { - BasicInfo, - getBorderRadiusValue, - GlobalSettings, -} from "@/types/resume"; -import { ResumeTemplate } from "@/types/template"; -import { useResumeStore } from "@/store/useResumeStore"; - -interface BaseInfoProps { - basic: BasicInfo | undefined; - globalSettings: GlobalSettings | undefined; - template?: ResumeTemplate; - showTitle?: boolean; -} - -const BaseInfo = ({ - basic = {} as BasicInfo, - globalSettings, - template, -}: BaseInfoProps) => { - const t = useTranslations("workbench"); - const locale = useLocale(); - const { setActiveSection } = useResumeStore(); - const useIconMode = globalSettings?.useIconMode ?? false; - const layout = basic?.layout || "left"; - - const getIcon = (iconName: string | undefined) => { - const IconComponent = Icons[ - iconName as keyof typeof Icons - ] as React.ElementType; - return IconComponent ? : null; - }; - - const isModernTemplate = React.useMemo(() => { - return template?.layout === "modern"; - }, [template]); - - const isWhiteTextTemplate = React.useMemo(() => { - return template?.layout === "modern" || template?.layout === "creative"; - }, [template]); - - const getOrderedFields = React.useMemo(() => { - if (!basic.fieldOrder) { - return [ - { - key: "email", - value: basic.email, - icon: basic.icons?.email || "Mail", - label: "电子邮箱", - visible: true, - custom: false, - }, - ].filter((item) => Boolean(item.value && item.visible)); - } - - return basic.fieldOrder - .filter( - (field) => - field.visible !== false && - field.key !== "name" && - field.key !== "title" - ) - .map((field) => ({ - key: field.key, - value: - field.key === "birthDate" && basic[field.key] - ? formatDateString(basic[field.key] as string, locale) - : (basic[field.key] as string), - icon: basic.icons?.[field.key] || "User", - label: field.label, - visible: field.visible, - custom: field.custom, - })) - .filter((item) => Boolean(item.value)); - }, [basic]); - - const allFields = [ - ...getOrderedFields, - ...(basic.customFields - ?.filter((field) => field.visible !== false) - .map((field) => ({ - key: field.id, - value: field.value, - icon: field.icon, - label: field.label, - visible: true, - custom: true, - })) || []), - ]; - - const getNameField = () => { - const nameField = basic.fieldOrder?.find( - (field) => field.key === "name" - ) || { - key: "name", - label: "姓名", - visible: true, - }; - return nameField.visible !== false ? nameField : null; - }; - - const getTitleField = () => { - const titleField = basic.fieldOrder?.find( - (field) => field.key === "title" - ) || { - key: "title", - label: "职位", - visible: true, - }; - return titleField.visible !== false ? titleField : null; - }; - - const nameField = getNameField(); - const titleField = getTitleField(); - - const PhotoComponent = basic.photo && basic.photoConfig?.visible && ( - -
- {`${basic.name}'s -
-
- ); - - // 基础样式 - const baseContainerClass = - "hover:cursor-pointer hover:bg-gray-100 rounded-md transition-all duration-300 ease-in-out hover:shadow-md"; - const baseFieldsClass = ""; - const baseFieldItemClass = - "flex items-center whitespace-nowrap overflow-hidden text-baseFont"; - const baseNameTitleClass = "flex flex-col"; - - // 左对齐布局样式 - const leftLayoutStyles = { - container: "flex items-center justify-between gap-6", - leftContent: "flex items-center gap-6 ", - fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", - nameTitle: "text-left", - }; - - // 右对齐布局样式 - const rightLayoutStyles = { - container: "flex items-center justify-between gap-6 flex-row-reverse", - leftContent: "flex justify-end items-center gap-6 ", - fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", - nameTitle: "text-right", - }; - - // 居中布局样式 - const centerLayoutStyles = { - container: "flex flex-col items-center gap-3", - leftContent: "flex flex-col items-center gap-4", - fields: "w-full flex justify-start items-center flex-wrap gap-3", - nameTitle: "text-center", - }; - - // 根据布局选择样式 - const getLayoutStyles = () => { - switch (layout) { - case "right": - return rightLayoutStyles; - case "center": - return centerLayoutStyles; - default: - return leftLayoutStyles; - } - }; - - const layoutStyles = getLayoutStyles(); - - const containerClass = cn(baseContainerClass, layoutStyles.container); - const leftContentClass = cn(layoutStyles.leftContent); - const fieldsContainerClass = cn(baseFieldsClass, layoutStyles.fields); - const nameTitleClass = cn(baseNameTitleClass, layoutStyles.nameTitle); - - const NameTitleComponent = ( -
- {nameField && basic[nameField.key] && ( - - {basic[nameField.key] as string} - - )} - {titleField && basic[titleField.key] && ( - - {basic[titleField.key] as string} - - )} -
- ); - - const FieldsComponent = ( - - {allFields.map((item) => ( - - {useIconMode ? ( -
- {getIcon(item.icon)} - {item.key === "email" ? ( - - {item.value} - - ) : ( - {item.value} - )} -
- ) : ( -
- {!item.custom && ( - {t(`basicPanel.basicFields.${item.key}`)}: - )} - {item.custom && {item.label}:} - - {item.value} - -
- )} -
- ))} -
- ); - - return ( -
setActiveSection("basic")}> -
- {PhotoComponent} - {NameTitleComponent} -
- {FieldsComponent} -
- ); -}; - -export default BaseInfo; diff --git a/src/components/preview/CustomSection.tsx b/src/components/preview/CustomSection.tsx deleted file mode 100644 index 20cf68f..0000000 --- a/src/components/preview/CustomSection.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { AnimatePresence, motion } from "framer-motion"; -import SectionTitle from "./SectionTitle"; -import { GlobalSettings, CustomItem } from "@/types/resume"; -import { useResumeStore } from "@/store/useResumeStore"; -import { normalizeRichTextContent } from "@/lib/richText"; -import { formatDateString } from "@/lib/utils"; -import { useLocale } from "@/i18n/compat/client"; - -interface CustomSectionProps { - sectionId: string; - title: string; - items: CustomItem[]; - globalSettings?: GlobalSettings; - showTitle?: boolean; -} - -const CustomSection = ({ - sectionId, - title, - items, - globalSettings, - showTitle = true, -}: CustomSectionProps) => { - const { setActiveSection } = useResumeStore(); - const locale = useLocale(); - const visibleItems = items?.filter((item) => { - return item.visible && (item.title || item.description); - }); - - const centerSubtitle = globalSettings?.centerSubtitle; - const gridColumns = centerSubtitle ? 3 : 2; - - return ( - { - setActiveSection(sectionId); - }} - > - - - {visibleItems.map((item) => ( - - -
-

- {item.title} -

-
- - {centerSubtitle && ( - - {item.subtitle} - - )} - - - {formatDateString(item.dateRange, locale)} - -
- - {!centerSubtitle && item.subtitle && ( - - {item.subtitle} - - )} - - {item.description && ( - - )} - - ))} -
-
- ); -}; - -export default CustomSection; diff --git a/src/components/preview/EducationSection.tsx b/src/components/preview/EducationSection.tsx deleted file mode 100644 index 8b096e3..0000000 --- a/src/components/preview/EducationSection.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { AnimatePresence, motion } from "framer-motion"; -import { Education, GlobalSettings } from "@/types/resume"; -import SectionTitle from "./SectionTitle"; -import { useResumeStore } from "@/store/useResumeStore"; -import { useLocale } from "@/i18n/compat/client"; -import { normalizeRichTextContent } from "@/lib/richText"; -import { formatDateString } from "@/lib/utils"; - -interface EducationSectionProps { - education?: Education[]; - globalSettings?: GlobalSettings; - showTitle?: boolean; -} - -const EducationSection = ({ - education, - globalSettings, - showTitle = true, -}: EducationSectionProps) => { - const { setActiveSection } = useResumeStore(); - const locale = useLocale(); - const visibleEducation = education?.filter((edu) => edu.visible); - return ( - { - setActiveSection("education"); - }} - > - - - {visibleEducation?.map((edu) => ( - - -
- {edu.school} -
- - {globalSettings?.centerSubtitle && ( - - {[edu.major, edu.degree].filter(Boolean).join(" · ")} - {edu.gpa && ` · GPA ${edu.gpa}`} - - )} - - - {`${formatDateString(edu.startDate, locale)} - ${formatDateString( - edu.endDate, - locale - )}`} - -
- - {!globalSettings?.centerSubtitle && ( - - {[edu.major, edu.degree].filter(Boolean).join(" · ")} - {edu.gpa && ` · GPA ${edu.gpa}`} - - )} - - {edu.description && ( - - )} - - ))} -
-
- ); -}; - -export default EducationSection; diff --git a/src/components/preview/ExperienceSection.tsx b/src/components/preview/ExperienceSection.tsx deleted file mode 100644 index 9df9eb7..0000000 --- a/src/components/preview/ExperienceSection.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import React from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { Experience, GlobalSettings } from "@/types/resume"; -import SectionTitle from "./SectionTitle"; -import { useResumeStore } from "@/store/useResumeStore"; -import { normalizeRichTextContent } from "@/lib/richText"; -import { formatDateString } from "@/lib/utils"; -import { useLocale } from "@/i18n/compat/client"; - -interface ExperienceSectionProps { - experiences?: Experience[]; - globalSettings?: GlobalSettings; - showTitle?: boolean; -} - -interface ExperienceItemProps { - experience: Experience; - globalSettings?: GlobalSettings; -} - -const ExperienceItem = React.forwardRef( - ({ experience, globalSettings }, ref) => { - const centerSubtitle = globalSettings?.centerSubtitle; - const flexLayout = globalSettings?.flexibleHeaderLayout; - const gridColumns = centerSubtitle ? 3 : 2; - const locale = useLocale(); - - return ( - - -
- {experience.company} -
- {centerSubtitle && ( - - {experience.position} - - )} -
- {formatDateString(experience.date, locale)} -
-
- {experience.position && !centerSubtitle && ( - - {experience.position} - - )} - {experience.details && ( - - )} -
- ); - } -); - -ExperienceItem.displayName = "ExperienceItem"; - -const ExperienceSection: React.FC = ({ - experiences, - globalSettings, - showTitle = true, -}) => { - const { setActiveSection } = useResumeStore(); - - const visibleExperiences = experiences?.filter( - (experience) => experience.visible - ); - - return ( - { - setActiveSection("experience"); - }} - > - {showTitle && ( - - )} -
- - {visibleExperiences?.map((experience) => ( - - ))} - -
-
- ); -}; - -export default ExperienceSection; diff --git a/src/components/preview/ProjectSection.tsx b/src/components/preview/ProjectSection.tsx deleted file mode 100644 index fd0beb3..0000000 --- a/src/components/preview/ProjectSection.tsx +++ /dev/null @@ -1,185 +0,0 @@ - -import React from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import SectionTitle from "./SectionTitle"; -import { Project, GlobalSettings } from "@/types/resume"; -import { useResumeStore } from "@/store/useResumeStore"; -import { normalizeRichTextContent } from "@/lib/richText"; -import { formatDateString } from "@/lib/utils"; -import { useLocale } from "@/i18n/compat/client"; - -interface ProjectItemProps { - project: Project; - globalSettings?: GlobalSettings; -} - -const ProjectItem = React.forwardRef( - ({ project, globalSettings }, ref) => { - const centerSubtitle = globalSettings?.centerSubtitle; - const locale = useLocale(); - - const flexLayout = globalSettings?.flexibleHeaderLayout; - - return ( - - -
-

- {project.name} -

-
- - {project.link && !centerSubtitle && ( - - {(() => { - try { - const url = new URL( - project.link.startsWith("http") - ? project.link - : `https://${project.link}` - ); - return url.hostname.replace(/^www\./, ""); - } catch (e) { - return project.link; - } - })()} - - )} - - {!project.link && !centerSubtitle && !flexLayout &&
} - - {centerSubtitle && ( - - {project.role} - - )} -
- {formatDateString(project.date, locale)} -
-
- {project.role && !centerSubtitle && ( - - {project.role} - - )} - {project.link && centerSubtitle && ( - - {project.link} - - )} - {project.description ? ( - - ) : ( -
- )} -
- ); - } -); - -ProjectItem.displayName = "ProjectItem"; - -interface ProjectSectionProps { - projects: Project[]; - globalSettings?: GlobalSettings; - showTitle?: boolean; -} - -const ProjectSection: React.FC = ({ - projects, - globalSettings, - showTitle = true, -}) => { - const { setActiveSection } = useResumeStore(); - - const visibleProjects = projects?.filter((project) => project.visible); - - return ( - { - setActiveSection("projects"); - }} - > - - - - {visibleProjects.map((project) => ( - - ))} - - - - ); -}; - -export default ProjectSection; diff --git a/src/components/preview/SectionTitle.tsx b/src/components/preview/SectionTitle.tsx deleted file mode 100644 index 1c09690..0000000 --- a/src/components/preview/SectionTitle.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { useMemo } from "react"; -import { GlobalSettings } from "@/types/resume"; -import { useResumeStore } from "@/store/useResumeStore"; -import { cn } from "@/lib/utils"; -import { templateConfigs } from "@/config/templates"; -import { useTemplateContext } from "../templates/TemplateContext"; - -interface SectionTitleProps { - globalSettings?: GlobalSettings; - type: string; - title?: string; - showTitle?: boolean; -} - -const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => { - const { activeResume } = useResumeStore(); - const templateContext = useTemplateContext(); - - const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? []; - const templateId = templateContext?.templateId ?? activeResume?.templateId ?? "default"; - - const renderTitle = useMemo(() => { - if (type === "custom") { - return title; - } - const sectionConfig = menuSections.find((s) => s.id === type); - return sectionConfig?.title; - }, [menuSections, type, title]); - - const config = - templateConfigs[templateId as string] || templateConfigs["default"]; - const { styles } = config.sectionTitle; - - const themeColor = globalSettings?.themeColor; - - const baseStyles = useMemo( - () => ({ - fontSize: `${globalSettings?.headerSize || styles.fontSize}px`, - fontWeight: "bold", - color: themeColor, - marginBottom: `${globalSettings?.paragraphSpacing}px`, - }), - [ - globalSettings?.headerSize, - globalSettings?.paragraphSpacing, - styles.fontSize, - themeColor, - ] - ); - - const renderTemplateTitle = () => { - if (!showTitle) return null; - switch (templateId) { - case "modern": - return ( -

- {renderTitle} -

- ); - - case "left-right": - return ( -
-
-

- {renderTitle} -

-
- ); - - case "classic": - return ( -

- {renderTitle} -

- ); - - case "minimalist": - return ( -

- {renderTitle} -

- ); - - - - case "elegant": - return ( -
- -

- {renderTitle} -

-
- ); - - case "creative": - return ( -

- {renderTitle} -

- ); - - - - default: - return ( -

- {renderTitle} -

- ); - } - }; - - return renderTemplateTitle(); -}; - -export default SectionTitle; diff --git a/src/components/preview/SkillPanel.tsx b/src/components/preview/SkillPanel.tsx deleted file mode 100644 index 73b2212..0000000 --- a/src/components/preview/SkillPanel.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { motion } from "framer-motion"; -import SectionTitle from "./SectionTitle"; -import { GlobalSettings } from "@/types/resume"; -import { useResumeStore } from "@/store/useResumeStore"; -import { normalizeRichTextContent } from "@/lib/richText"; - -interface SkillSectionProps { - skill?: string; - globalSettings?: GlobalSettings; - showTitle?: boolean; -} - -const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => { - const { setActiveSection } = useResumeStore(); - - return ( - { - setActiveSection("skills"); - }} - > - {showTitle && } - - - - - ); -}; - -export default SkillSection; diff --git a/src/components/templates/ClassicTemplate.tsx b/src/components/templates/ClassicTemplate.tsx deleted file mode 100644 index 1f95bde..0000000 --- a/src/components/templates/ClassicTemplate.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React from "react"; -import GithubContribution from "@/components/shared/GithubContribution"; -import BaseInfo from "../preview/BaseInfo"; -import ExperienceSection from "../preview/ExperienceSection"; -import EducationSection from "../preview/EducationSection"; -import SkillSection from "../preview/SkillPanel"; -import CustomSection from "../preview/CustomSection"; -import { ResumeData } from "@/types/resume"; -import { ResumeTemplate } from "@/types/template"; -import ProjectSection from "../preview/ProjectSection"; - -interface ClassicTemplateProps { - data: ResumeData; - template: ResumeTemplate; -} - -const ClassicTemplate: React.FC = ({ - data, - template, -}) => { - const { colorScheme } = template; - const enabledSections = data.menuSections.filter( - (section) => section.enabled - ); - const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order); - - const renderSection = (sectionId: string) => { - switch (sectionId) { - case "basic": - return ( - <> - - - {data.basic.githubContributionsVisible && ( - - )} - - ); - case "experience": - return ( - - ); - case "education": - return ( - - ); - case "skills": - return ( - - ); - case "projects": - return ( - - ); - default: - if (sectionId in data.customData) { - const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId; - return ( - - ); - } - return null; - } - }; - - return ( -
- {sortedSections.map((section) => ( -
{renderSection(section.id)}
- ))} -
- ); -}; - -export default ClassicTemplate; diff --git a/src/components/templates/CreativeTemplate.tsx b/src/components/templates/CreativeTemplate.tsx deleted file mode 100644 index 77bbb47..0000000 --- a/src/components/templates/CreativeTemplate.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React from "react"; -import BaseInfo from "../preview/BaseInfo"; -import ExperienceSection from "../preview/ExperienceSection"; -import EducationSection from "../preview/EducationSection"; -import SkillSection from "../preview/SkillPanel"; -import ProjectSection from "../preview/ProjectSection"; -import CustomSection from "../preview/CustomSection"; -import { ResumeData } from "@/types/resume"; -import { ResumeTemplate } from "@/types/template"; -import GithubContribution from "@/components/shared/GithubContribution"; - -interface CreativeTemplateProps { - data: ResumeData; - template: ResumeTemplate; -} - -const CreativeTemplate: React.FC = ({ - data, - template, -}) => { - const { colorScheme } = template; - const enabledSections = data.menuSections.filter( - (section) => section.enabled - ); - const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order); - - const renderSection = (sectionId: string) => { - switch (sectionId) { - case "experience": - return ( - - ); - case "education": - return ( - - ); - case "skills": - return ( - - ); - case "projects": - return ( - - ); - default: - if (sectionId in data.customData) { - const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId; - return ( - - ); - } - return null; - } - }; - - const basicSection = sortedSections.find((section) => section.id === "basic"); - const otherSections = sortedSections.filter( - (section) => section.id !== "basic" - ); - - return ( -
- {/* 顶部色块(如果存在basic区块) */} - {basicSection && ( -
-
- - {data.basic.githubContributionsVisible && ( - - )} -
-
- )} - - {/* 下方内容区 */} -
- {otherSections.map((section) => ( -
{renderSection(section.id)}
- ))} -
-
- ); -}; - -export default CreativeTemplate; diff --git a/src/components/templates/ElegantTemplate.tsx b/src/components/templates/ElegantTemplate.tsx deleted file mode 100644 index c161dec..0000000 --- a/src/components/templates/ElegantTemplate.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React from "react"; -import BaseInfo from "../preview/BaseInfo"; -import ExperienceSection from "../preview/ExperienceSection"; -import EducationSection from "../preview/EducationSection"; -import SkillSection from "../preview/SkillPanel"; -import ProjectSection from "../preview/ProjectSection"; -import CustomSection from "../preview/CustomSection"; -import { ResumeData } from "@/types/resume"; -import { ResumeTemplate } from "@/types/template"; -import GithubContribution from "@/components/shared/GithubContribution"; - -interface ElegantTemplateProps { - data: ResumeData; - template: ResumeTemplate; -} - -const ElegantTemplate: React.FC = ({ - data, - template, -}) => { - const { colorScheme } = template; - const enabledSections = data.menuSections.filter( - (section) => section.enabled - ); - const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order); - - const renderSection = (sectionId: string) => { - switch (sectionId) { - case "basic": - return ( -
- - - {data.basic.githubContributionsVisible && ( - - )} -
- ); - case "experience": - return ( - - ); - case "education": - return ( - - ); - case "skills": - return ( - - ); - case "projects": - return ( - - ); - default: - if (sectionId in data.customData) { - const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId; - return ( - - ); - } - return null; - } - }; - - return ( -
- {/* 优雅版采用单行且较为居中的排版风格 */} -
- {sortedSections.map((section) => ( -
- {renderSection(section.id)} -
- ))} -
-
- ); -}; - -export default ElegantTemplate; diff --git a/src/components/templates/LeftRightTemplate.tsx b/src/components/templates/LeftRightTemplate.tsx deleted file mode 100644 index 015051c..0000000 --- a/src/components/templates/LeftRightTemplate.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import BaseInfo from "../preview/BaseInfo"; -import ExperienceSection from "../preview/ExperienceSection"; -import EducationSection from "../preview/EducationSection"; -import SkillSection from "../preview/SkillPanel"; -import ProjectSection from "../preview/ProjectSection"; -import CustomSection from "../preview/CustomSection"; -import { ResumeData } from "@/types/resume"; -import { ResumeTemplate } from "@/types/template"; -import GithubContribution from "../shared/GithubContribution"; - -interface LeftRightTemplateProps { - data: ResumeData; - template: ResumeTemplate; -} - -const LeftRightTemplate: React.FC = ({ - data, - template, -}) => { - const { colorScheme } = template; - const enabledSections = data.menuSections.filter( - (section) => section.enabled - ); - const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order); - - const renderSection = (sectionId: string) => { - switch (sectionId) { - case "basic": - return ( - <> - - {data.basic.githubContributionsVisible && ( - - )} - - ); - case "experience": - return ( - - ); - case "education": - return ( - - ); - case "skills": - return ( - - ); - case "projects": - return ( - - ); - default: - if (sectionId in data.customData) { - const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId; - return ( - - ); - } - return null; - } - }; - - return ( -
- {sortedSections.map((section) => ( -
{renderSection(section.id)}
- ))} -
- ); -}; - -export default LeftRightTemplate; diff --git a/src/components/templates/MinimalistTemplate.tsx b/src/components/templates/MinimalistTemplate.tsx deleted file mode 100644 index fc6c69b..0000000 --- a/src/components/templates/MinimalistTemplate.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import React from "react"; -import GithubContribution from "@/components/shared/GithubContribution"; -import BaseInfo from "../preview/BaseInfo"; -import ExperienceSection from "../preview/ExperienceSection"; -import EducationSection from "../preview/EducationSection"; -import SkillSection from "../preview/SkillPanel"; -import CustomSection from "../preview/CustomSection"; -import { ResumeData } from "@/types/resume"; -import { ResumeTemplate } from "@/types/template"; -import ProjectSection from "../preview/ProjectSection"; - -interface MinimalistTemplateProps { - data: ResumeData; - template: ResumeTemplate; -} - -const MinimalistTemplate: React.FC = ({ - data, - template, -}) => { - const { colorScheme } = template; - const enabledSections = data.menuSections.filter( - (section) => section.enabled - ); - const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order); - - const renderSection = (sectionId: string) => { - switch (sectionId) { - case "basic": - return ( - <> - - - {data.basic.githubContributionsVisible && ( - - )} - - ); - case "experience": - return ( - - ); - case "education": - return ( - - ); - case "skills": - return ( - - ); - case "projects": - return ( - - ); - default: - if (sectionId in data.customData) { - const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId; - return ( - - ); - } - return null; - } - }; - - return ( -
- {/* 极简风格:单列居中体验,更大留白 */} - {sortedSections.map((section) => ( -
- {renderSection(section.id)} -
- ))} -
- ); -}; - -export default MinimalistTemplate; diff --git a/src/components/templates/ModernTemplate.tsx b/src/components/templates/ModernTemplate.tsx deleted file mode 100644 index c6f6409..0000000 --- a/src/components/templates/ModernTemplate.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from "react"; -import BaseInfo from "../preview/BaseInfo"; -import ExperienceSection from "../preview/ExperienceSection"; -import EducationSection from "../preview/EducationSection"; -import SkillSection from "../preview/SkillPanel"; -import ProjectSection from "../preview/ProjectSection"; -import CustomSection from "../preview/CustomSection"; -import { ResumeData } from "@/types/resume"; -import { ResumeTemplate } from "@/types/template"; - -interface ModernTemplateProps { - data: ResumeData; - template: ResumeTemplate; -} - -const ModernTemplate: React.FC = ({ data, template }) => { - const { colorScheme } = template; - const enabledSections = data.menuSections.filter( - (section) => section.enabled - ); - const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order); - - const renderSection = (sectionId: string) => { - switch (sectionId) { - case "basic": - return ( - - ); - case "experience": - return ( - - ); - case "education": - return ( - - ); - case "skills": - return ( - - ); - case "projects": - return ( - - ); - default: - if (sectionId in data.customData) { - const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId; - return ( - - ); - } - return null; - } - }; - - const basicSection = sortedSections.find((section) => section.id === "basic"); - const otherSections = sortedSections.filter( - (section) => section.id !== "basic" - ); - - return ( -
-
- {basicSection && renderSection(basicSection.id)} -
- -
- {otherSections.map((section) => ( -
{renderSection(section.id)}
- ))} -
-
- ); -}; - -export default ModernTemplate; diff --git a/src/components/templates/TimelineTemplate.tsx b/src/components/templates/TimelineTemplate.tsx deleted file mode 100644 index 169a638..0000000 --- a/src/components/templates/TimelineTemplate.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import React from "react"; -import GithubContribution from "@/components/shared/GithubContribution"; -import BaseInfo from "../preview/BaseInfo"; -import ExperienceSection from "../preview/ExperienceSection"; -import EducationSection from "../preview/EducationSection"; -import SkillSection from "../preview/SkillPanel"; -import CustomSection from "../preview/CustomSection"; -import { ResumeData } from "@/types/resume"; -import { ResumeTemplate } from "@/types/template"; -import ProjectSection from "../preview/ProjectSection"; - -interface TimelineTemplateProps { - data: ResumeData; - template: ResumeTemplate; -} - -const TimelineTemplate: React.FC = ({ - data, - template, -}) => { - const { colorScheme } = template; - const enabledSections = data.menuSections.filter( - (section) => section.enabled - ); - const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order); - - const renderTimelineItem = (content: React.ReactNode, title: string) => ( -
-
-
-
- {title} -
-
{content}
-
- ); - - const renderSection = (sectionId: string) => { - const sectionTitle = - data.menuSections.find((s) => s.id === sectionId)?.title || sectionId; - - switch (sectionId) { - case "basic": - return ( - <> - - {data.basic.githubContributionsVisible && ( - - )} - - ); - case "experience": - return ( -
- {renderTimelineItem( - , - sectionTitle - )} -
- ); - case "education": - return ( -
- {renderTimelineItem( - , - sectionTitle - )} -
- ); - case "skills": - return ( -
- {renderTimelineItem( - , - sectionTitle - )} -
- ); - case "projects": - return ( -
- {renderTimelineItem( - , - sectionTitle - )} -
- ); - default: - if (sectionId in data.customData) { - return ( -
- {renderTimelineItem( - , - sectionTitle - )} -
- ); - } - return null; - } - }; - - return ( -
- {sortedSections.map((section) => ( -
- {renderSection(section.id)} -
- ))} -
- ); -}; - -export default TimelineTemplate; diff --git a/src/components/templates/classic/config.ts b/src/components/templates/classic/config.ts new file mode 100644 index 0000000..f342405 --- /dev/null +++ b/src/components/templates/classic/config.ts @@ -0,0 +1,23 @@ +import { ResumeTemplate } from "@/types/template"; + +export const classicConfig: ResumeTemplate = { + id: "classic", + name: "经典模板", + description: "传统简约的简历布局,适合大多数求职场景", + thumbnail: "classic", + layout: "classic", + colorScheme: { + primary: "#000000", + secondary: "#4b5563", + background: "#ffffff", + text: "#212529", + }, + spacing: { + sectionGap: 24, + itemGap: 16, + contentPadding: 32, + }, + basic: { + layout: "center", + }, +}; diff --git a/src/components/templates/classic/index.tsx b/src/components/templates/classic/index.tsx new file mode 100644 index 0000000..75d74bb --- /dev/null +++ b/src/components/templates/classic/index.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { ResumeData } from "@/types/resume"; +import { ResumeTemplate } from "@/types/template"; +import BaseInfo from "./sections/BaseInfo"; +import ExperienceSection from "./sections/ExperienceSection"; +import EducationSection from "./sections/EducationSection"; +import ProjectSection from "./sections/ProjectSection"; +import SkillSection from "./sections/SkillSection"; +import CustomSection from "./sections/CustomSection"; + +interface ClassicTemplateProps { + data: ResumeData; + template: ResumeTemplate; +} + +const ClassicTemplate: React.FC = ({ data, template }) => { + const { colorScheme } = template; + const enabledSections = data.menuSections.filter((s) => s.enabled).sort((a, b) => a.order - b.order); + + const renderSection = (sectionId: string) => { + switch (sectionId) { + case "basic": + return ; + case "experience": + return ; + case "education": + return ; + case "skills": + return ; + case "projects": + return ; + default: + if (sectionId in data.customData) { + const sectionTitle = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId; + return ; + } + return null; + } + }; + + return ( +
+ {enabledSections.map((section) => ( +
{renderSection(section.id)}
+ ))} +
+ ); +}; + +export default ClassicTemplate; diff --git a/src/components/templates/classic/sections/BaseInfo.tsx b/src/components/templates/classic/sections/BaseInfo.tsx new file mode 100644 index 0000000..e62b10c --- /dev/null +++ b/src/components/templates/classic/sections/BaseInfo.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { motion } from "framer-motion"; +import * as Icons from "lucide-react"; +import { cn, formatDateString } from "@/lib/utils"; +import { BasicInfo, getBorderRadiusValue, GlobalSettings } from "@/types/resume"; +import { ResumeTemplate } from "@/types/template"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { useTranslations, useLocale } from "@/i18n/compat/client"; +import GithubContribution from "@/components/shared/GithubContribution"; + +interface BaseInfoProps { + basic: BasicInfo | undefined; + globalSettings: GlobalSettings | undefined; + template?: ResumeTemplate; +} + +const BaseInfo = ({ basic = {} as BasicInfo, globalSettings, template }: BaseInfoProps) => { + const t = useTranslations("workbench"); + const locale = useLocale(); + const useIconMode = globalSettings?.useIconMode ?? false; + const layout = basic?.layout || "left"; + + const getIcon = (iconName: string | undefined) => { + const IconComponent = Icons[iconName as keyof typeof Icons] as React.ElementType; + return IconComponent ? : null; + }; + + const getOrderedFields = React.useMemo(() => { + if (!basic.fieldOrder) { + return [{ key: "email", value: basic.email, icon: basic.icons?.email || "Mail", label: "电子邮箱", visible: true, custom: false }] + .filter((item) => Boolean(item.value && item.visible)); + } + return basic.fieldOrder + .filter((field) => field.visible !== false && field.key !== "name" && field.key !== "title") + .map((field) => ({ + key: field.key, value: field.key === "birthDate" && basic[field.key] ? formatDateString(basic[field.key] as string, locale) : (basic[field.key] as string), + icon: basic.icons?.[field.key] || "User", label: field.label, visible: field.visible, custom: field.custom, + })) + .filter((item) => Boolean(item.value)); + }, [basic]); + + const allFields = [ + ...getOrderedFields, + ...(basic.customFields?.filter((field) => field.visible !== false).map((field) => ({ + key: field.id, value: field.value, icon: field.icon, label: field.label, visible: true, custom: true, + })) || []), + ]; + + const nameField = basic.fieldOrder?.find((f) => f.key === "name") || { key: "name", label: "姓名", visible: true }; + const titleField = basic.fieldOrder?.find((f) => f.key === "title") || { key: "title", label: "职位", visible: true }; + + const PhotoComponent = basic.photo && basic.photoConfig?.visible && ( + +
+ {`${basic.name}'s +
+
+ ); + + const layoutStyles = { + left: { container: "flex items-center justify-between gap-6", leftContent: "flex items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-left" }, + right: { container: "flex items-center justify-between gap-6 flex-row-reverse", leftContent: "flex justify-end items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-right" }, + center: { container: "flex flex-col items-center gap-3", leftContent: "flex flex-col items-center gap-4", fields: "w-full flex justify-start items-center flex-wrap gap-3", nameTitle: "text-center" }, + }; + + const styles = layoutStyles[layout as keyof typeof layoutStyles] || layoutStyles.left; + + return ( + +
+
+ {PhotoComponent} +
+ {nameField.visible !== false && basic[nameField.key] && ( + {basic[nameField.key] as string} + )} + {titleField.visible !== false && basic[titleField.key] && ( + {basic[titleField.key] as string} + )} +
+
+ + {allFields.map((item) => ( + + {useIconMode ? ( +
+ {getIcon(item.icon)} + {item.key === "email" ? {item.value} : {item.value}} +
+ ) : ( +
+ {!item.custom && {t(`basicPanel.basicFields.${item.key}`)}:} + {item.custom && {item.label}:} + {item.value} +
+ )} +
+ ))} +
+
+ {basic.githubContributionsVisible && ( + + )} +
+ ); +}; + +export default BaseInfo; diff --git a/src/components/templates/classic/sections/CustomSection.tsx b/src/components/templates/classic/sections/CustomSection.tsx new file mode 100644 index 0000000..ff2f970 --- /dev/null +++ b/src/components/templates/classic/sections/CustomSection.tsx @@ -0,0 +1,58 @@ +import { AnimatePresence, motion } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { GlobalSettings, CustomItem } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface CustomSectionProps { + sectionId: string; + title: string; + items: CustomItem[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const CustomSection = ({ sectionId, title, items, globalSettings, showTitle = true }: CustomSectionProps) => { + const locale = useLocale(); + const visibleItems = items?.filter((item) => item.visible && (item.title || item.description)); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleItems.map((item) => ( + + +
+

{item.title}

+
+ {centerSubtitle && ( + + {item.subtitle} + + )} + + {formatDateString(item.dateRange, locale)} + +
+ {!centerSubtitle && item.subtitle && ( + {item.subtitle} + )} + {item.description && ( + + )} + + ))} +
+
+ ); +}; + +export default CustomSection; diff --git a/src/components/templates/classic/sections/EducationSection.tsx b/src/components/templates/classic/sections/EducationSection.tsx new file mode 100644 index 0000000..d49d094 --- /dev/null +++ b/src/components/templates/classic/sections/EducationSection.tsx @@ -0,0 +1,61 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { Education, GlobalSettings } from "@/types/resume"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { useLocale } from "@/i18n/compat/client"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; + +interface EducationSectionProps { + education?: Education[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const EducationSection = ({ education, globalSettings, showTitle = true }: EducationSectionProps) => { + const locale = useLocale(); + const visibleEducation = education?.filter((edu) => edu.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleEducation?.map((edu) => ( + + +
+ {edu.school} +
+ {centerSubtitle && ( + + {[edu.major, edu.degree].filter(Boolean).join(" · ")} + {edu.gpa && ` · GPA ${edu.gpa}`} + + )} + + {`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`} + +
+ {!centerSubtitle && ( + + {[edu.major, edu.degree].filter(Boolean).join(" · ")} + {edu.gpa && ` · GPA ${edu.gpa}`} + + )} + {edu.description && ( + + )} + + ))} +
+
+ ); +}; + +export default EducationSection; diff --git a/src/components/templates/classic/sections/ExperienceSection.tsx b/src/components/templates/classic/sections/ExperienceSection.tsx new file mode 100644 index 0000000..bb4d3ba --- /dev/null +++ b/src/components/templates/classic/sections/ExperienceSection.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Experience, GlobalSettings } from "@/types/resume"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface ExperienceSectionProps { + experiences?: Experience[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const ExperienceSection: React.FC = ({ experiences, globalSettings, showTitle = true }) => { + const locale = useLocale(); + const visibleExperiences = experiences?.filter((exp) => exp.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleExperiences?.map((exp) => ( + + +
+ {exp.company} +
+ {centerSubtitle && ( + + {exp.position} + + )} +
+ {formatDateString(exp.date, locale)} +
+
+ {exp.position && !centerSubtitle && ( + {exp.position} + )} + {exp.details && ( + + )} + + ))} +
+
+ ); +}; + +export default ExperienceSection; diff --git a/src/components/templates/classic/sections/ProjectSection.tsx b/src/components/templates/classic/sections/ProjectSection.tsx new file mode 100644 index 0000000..3607f72 --- /dev/null +++ b/src/components/templates/classic/sections/ProjectSection.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { Project, GlobalSettings } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface ProjectSectionProps { + projects: Project[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const ProjectSection: React.FC = ({ projects, globalSettings, showTitle = true }) => { + const locale = useLocale(); + const visibleProjects = projects?.filter((p) => p.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + + {visibleProjects.map((project) => ( + + +
+

{project.name}

+
+ {project.link && !centerSubtitle && ( + + {(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()} + + )} + {!project.link && !centerSubtitle && !flexLayout &&
} + {centerSubtitle && ( + + {project.role} + + )} +
+ {formatDateString(project.date, locale)} +
+ + {project.role && !centerSubtitle && ( + {project.role} + )} + {project.link && centerSubtitle && ( + {project.link} + )} + {project.description && ( + + )} + + ))} + + + + ); +}; + +export default ProjectSection; diff --git a/src/components/templates/classic/sections/SectionTitle.tsx b/src/components/templates/classic/sections/SectionTitle.tsx new file mode 100644 index 0000000..632ff42 --- /dev/null +++ b/src/components/templates/classic/sections/SectionTitle.tsx @@ -0,0 +1,42 @@ +import { useMemo } from "react"; +import { GlobalSettings } from "@/types/resume"; +import { useTemplateContext } from "../../TemplateContext"; +import { useResumeStore } from "@/store/useResumeStore"; + +interface SectionTitleProps { + globalSettings?: GlobalSettings; + type: string; + title?: string; + showTitle?: boolean; +} + +const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => { + const { activeResume } = useResumeStore(); + const templateContext = useTemplateContext(); + const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? []; + + const renderTitle = useMemo(() => { + if (type === "custom") return title; + return menuSections.find((s) => s.id === type)?.title; + }, [menuSections, type, title]); + + const themeColor = globalSettings?.themeColor; + + if (!showTitle) return null; + + return ( +

+ {renderTitle} +

+ ); +}; + +export default SectionTitle; diff --git a/src/components/templates/classic/sections/SkillSection.tsx b/src/components/templates/classic/sections/SkillSection.tsx new file mode 100644 index 0000000..6721caf --- /dev/null +++ b/src/components/templates/classic/sections/SkillSection.tsx @@ -0,0 +1,27 @@ +import { motion } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { GlobalSettings } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; + +interface SkillSectionProps { + skill?: string; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => { + return ( + + + + + + + ); +}; + +export default SkillSection; diff --git a/src/components/templates/creative/config.ts b/src/components/templates/creative/config.ts new file mode 100644 index 0000000..bbeffa4 --- /dev/null +++ b/src/components/templates/creative/config.ts @@ -0,0 +1,23 @@ +import { ResumeTemplate } from "@/types/template"; + +export const creativeConfig: ResumeTemplate = { + id: "creative", + name: "创意模板", + description: "视觉错落设计,灵动活泼展现个性", + thumbnail: "creative", + layout: "creative", + colorScheme: { + primary: "#18181b", + secondary: "#64748b", + background: "#ffffff", + text: "#1e293b", + }, + spacing: { + sectionGap: 24, + itemGap: 16, + contentPadding: 28, + }, + basic: { + layout: "left", + }, +}; diff --git a/src/components/templates/creative/index.tsx b/src/components/templates/creative/index.tsx new file mode 100644 index 0000000..7d0332b --- /dev/null +++ b/src/components/templates/creative/index.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { ResumeData } from "@/types/resume"; +import { ResumeTemplate } from "@/types/template"; +import BaseInfo from "./sections/BaseInfo"; +import GithubContribution from "@/components/shared/GithubContribution"; +import ExperienceSection from "./sections/ExperienceSection"; +import EducationSection from "./sections/EducationSection"; +import ProjectSection from "./sections/ProjectSection"; +import SkillSection from "./sections/SkillSection"; +import CustomSection from "./sections/CustomSection"; + +interface CreativeTemplateProps { + data: ResumeData; + template: ResumeTemplate; +} + +const CreativeTemplate: React.FC = ({ data, template }) => { + const { colorScheme } = template; + const enabledSections = data.menuSections.filter((s) => s.enabled).sort((a, b) => a.order - b.order); + + const basicSection = enabledSections.find((s) => s.id === "basic"); + const otherSections = enabledSections.filter((s) => s.id !== "basic"); + + const renderSection = (sectionId: string) => { + switch (sectionId) { + case "experience": + return ; + case "education": + return ; + case "skills": + return ; + case "projects": + return ; + default: + if (sectionId in data.customData) { + const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId; + return ; + } + return null; + } + }; + + return ( +
+ {/* Top colored header block */} + {basicSection && ( +
+
+ + {data.basic.githubContributionsVisible && ( + + )} +
+
+ )} + {/* Content sections */} +
+ {otherSections.map((section) => ( +
{renderSection(section.id)}
+ ))} +
+
+ ); +}; + +export default CreativeTemplate; diff --git a/src/components/templates/creative/sections/BaseInfo.tsx b/src/components/templates/creative/sections/BaseInfo.tsx new file mode 100644 index 0000000..64c9737 --- /dev/null +++ b/src/components/templates/creative/sections/BaseInfo.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { motion } from "framer-motion"; +import * as Icons from "lucide-react"; +import { cn, formatDateString } from "@/lib/utils"; +import { BasicInfo, getBorderRadiusValue, GlobalSettings } from "@/types/resume"; +import { ResumeTemplate } from "@/types/template"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { useTranslations, useLocale } from "@/i18n/compat/client"; + +interface BaseInfoProps { + basic: BasicInfo | undefined; + globalSettings: GlobalSettings | undefined; + template?: ResumeTemplate; +} + +/** + * Creative template BaseInfo — white text, designed for the colored header block. + */ +const BaseInfo = ({ basic = {} as BasicInfo, globalSettings, template }: BaseInfoProps) => { + const t = useTranslations("workbench"); + const locale = useLocale(); + const useIconMode = globalSettings?.useIconMode ?? false; + const layout = basic?.layout || "left"; + + const getIcon = (iconName: string | undefined) => { + const IconComponent = Icons[iconName as keyof typeof Icons] as React.ElementType; + return IconComponent ? : null; + }; + + const getOrderedFields = React.useMemo(() => { + if (!basic.fieldOrder) { + return [{ key: "email", value: basic.email, icon: basic.icons?.email || "Mail", label: "电子邮箱", visible: true, custom: false }] + .filter((item) => Boolean(item.value && item.visible)); + } + return basic.fieldOrder + .filter((field) => field.visible !== false && field.key !== "name" && field.key !== "title") + .map((field) => ({ + key: field.key, value: field.key === "birthDate" && basic[field.key] ? formatDateString(basic[field.key] as string, locale) : (basic[field.key] as string), + icon: basic.icons?.[field.key] || "User", label: field.label, visible: field.visible, custom: field.custom, + })) + .filter((item) => Boolean(item.value)); + }, [basic]); + + const allFields = [ + ...getOrderedFields, + ...(basic.customFields?.filter((field) => field.visible !== false).map((field) => ({ + key: field.id, value: field.value, icon: field.icon, label: field.label, visible: true, custom: true, + })) || []), + ]; + + const nameField = basic.fieldOrder?.find((f) => f.key === "name") || { key: "name", visible: true }; + const titleField = basic.fieldOrder?.find((f) => f.key === "title") || { key: "title", visible: true }; + + const PhotoComponent = basic.photo && basic.photoConfig?.visible && ( + +
+ {`${basic.name}'s +
+
+ ); + + const layoutStyles = { + left: { container: "flex items-center justify-between gap-6", leftContent: "flex items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-left" }, + right: { container: "flex items-center justify-between gap-6 flex-row-reverse", leftContent: "flex justify-end items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-right" }, + center: { container: "flex flex-col items-center gap-3", leftContent: "flex flex-col items-center gap-4", fields: "w-full flex justify-start items-center flex-wrap gap-3", nameTitle: "text-center" }, + }; + + const styles = layoutStyles[layout as keyof typeof layoutStyles] || layoutStyles.left; + + return ( + +
+
+ {PhotoComponent} +
+ {nameField.visible !== false && basic[nameField.key] && ( + {basic[nameField.key] as string} + )} + {titleField.visible !== false && basic[titleField.key] && ( + {basic[titleField.key] as string} + )} +
+
+ + {allFields.map((item) => ( + + {useIconMode ? ( +
+ {getIcon(item.icon)} + {item.key === "email" ? {item.value} : {item.value}} +
+ ) : ( +
+ {!item.custom && {t(`basicPanel.basicFields.${item.key}`)}:} + {item.custom && {item.label}:} + {item.value} +
+ )} +
+ ))} +
+
+
+ ); +}; + +export default BaseInfo; diff --git a/src/components/templates/creative/sections/CustomSection.tsx b/src/components/templates/creative/sections/CustomSection.tsx new file mode 100644 index 0000000..ff2f970 --- /dev/null +++ b/src/components/templates/creative/sections/CustomSection.tsx @@ -0,0 +1,58 @@ +import { AnimatePresence, motion } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { GlobalSettings, CustomItem } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface CustomSectionProps { + sectionId: string; + title: string; + items: CustomItem[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const CustomSection = ({ sectionId, title, items, globalSettings, showTitle = true }: CustomSectionProps) => { + const locale = useLocale(); + const visibleItems = items?.filter((item) => item.visible && (item.title || item.description)); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleItems.map((item) => ( + + +
+

{item.title}

+
+ {centerSubtitle && ( + + {item.subtitle} + + )} + + {formatDateString(item.dateRange, locale)} + +
+ {!centerSubtitle && item.subtitle && ( + {item.subtitle} + )} + {item.description && ( + + )} + + ))} +
+
+ ); +}; + +export default CustomSection; diff --git a/src/components/templates/creative/sections/EducationSection.tsx b/src/components/templates/creative/sections/EducationSection.tsx new file mode 100644 index 0000000..d49d094 --- /dev/null +++ b/src/components/templates/creative/sections/EducationSection.tsx @@ -0,0 +1,61 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { Education, GlobalSettings } from "@/types/resume"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { useLocale } from "@/i18n/compat/client"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; + +interface EducationSectionProps { + education?: Education[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const EducationSection = ({ education, globalSettings, showTitle = true }: EducationSectionProps) => { + const locale = useLocale(); + const visibleEducation = education?.filter((edu) => edu.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleEducation?.map((edu) => ( + + +
+ {edu.school} +
+ {centerSubtitle && ( + + {[edu.major, edu.degree].filter(Boolean).join(" · ")} + {edu.gpa && ` · GPA ${edu.gpa}`} + + )} + + {`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`} + +
+ {!centerSubtitle && ( + + {[edu.major, edu.degree].filter(Boolean).join(" · ")} + {edu.gpa && ` · GPA ${edu.gpa}`} + + )} + {edu.description && ( + + )} + + ))} +
+
+ ); +}; + +export default EducationSection; diff --git a/src/components/templates/creative/sections/ExperienceSection.tsx b/src/components/templates/creative/sections/ExperienceSection.tsx new file mode 100644 index 0000000..bb4d3ba --- /dev/null +++ b/src/components/templates/creative/sections/ExperienceSection.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Experience, GlobalSettings } from "@/types/resume"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface ExperienceSectionProps { + experiences?: Experience[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const ExperienceSection: React.FC = ({ experiences, globalSettings, showTitle = true }) => { + const locale = useLocale(); + const visibleExperiences = experiences?.filter((exp) => exp.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleExperiences?.map((exp) => ( + + +
+ {exp.company} +
+ {centerSubtitle && ( + + {exp.position} + + )} +
+ {formatDateString(exp.date, locale)} +
+
+ {exp.position && !centerSubtitle && ( + {exp.position} + )} + {exp.details && ( + + )} + + ))} +
+
+ ); +}; + +export default ExperienceSection; diff --git a/src/components/templates/creative/sections/ProjectSection.tsx b/src/components/templates/creative/sections/ProjectSection.tsx new file mode 100644 index 0000000..3607f72 --- /dev/null +++ b/src/components/templates/creative/sections/ProjectSection.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { Project, GlobalSettings } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface ProjectSectionProps { + projects: Project[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const ProjectSection: React.FC = ({ projects, globalSettings, showTitle = true }) => { + const locale = useLocale(); + const visibleProjects = projects?.filter((p) => p.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + + {visibleProjects.map((project) => ( + + +
+

{project.name}

+
+ {project.link && !centerSubtitle && ( + + {(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()} + + )} + {!project.link && !centerSubtitle && !flexLayout &&
} + {centerSubtitle && ( + + {project.role} + + )} +
+ {formatDateString(project.date, locale)} +
+ + {project.role && !centerSubtitle && ( + {project.role} + )} + {project.link && centerSubtitle && ( + {project.link} + )} + {project.description && ( + + )} + + ))} + + + + ); +}; + +export default ProjectSection; diff --git a/src/components/templates/creative/sections/SectionTitle.tsx b/src/components/templates/creative/sections/SectionTitle.tsx new file mode 100644 index 0000000..e676d37 --- /dev/null +++ b/src/components/templates/creative/sections/SectionTitle.tsx @@ -0,0 +1,41 @@ +import { useMemo } from "react"; +import { GlobalSettings } from "@/types/resume"; +import { useTemplateContext } from "../../TemplateContext"; +import { useResumeStore } from "@/store/useResumeStore"; + +interface SectionTitleProps { + globalSettings?: GlobalSettings; + type: string; + title?: string; + showTitle?: boolean; +} + +const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => { + const { activeResume } = useResumeStore(); + const templateContext = useTemplateContext(); + const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? []; + + const renderTitle = useMemo(() => { + if (type === "custom") return title; + return menuSections.find((s) => s.id === type)?.title; + }, [menuSections, type, title]); + + const themeColor = globalSettings?.themeColor; + if (!showTitle) return null; + + return ( +

+ {renderTitle} +

+ ); +}; + +export default SectionTitle; diff --git a/src/components/templates/creative/sections/SkillSection.tsx b/src/components/templates/creative/sections/SkillSection.tsx new file mode 100644 index 0000000..6721caf --- /dev/null +++ b/src/components/templates/creative/sections/SkillSection.tsx @@ -0,0 +1,27 @@ +import { motion } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { GlobalSettings } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; + +interface SkillSectionProps { + skill?: string; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => { + return ( + + + + + + + ); +}; + +export default SkillSection; diff --git a/src/components/templates/elegant/config.ts b/src/components/templates/elegant/config.ts new file mode 100644 index 0000000..7a8fe4d --- /dev/null +++ b/src/components/templates/elegant/config.ts @@ -0,0 +1,23 @@ +import { ResumeTemplate } from "@/types/template"; + +export const elegantConfig: ResumeTemplate = { + id: "elegant", + name: "优雅模板", + description: "居中标题单列设计,具有高级感的分隔线", + thumbnail: "elegant", + layout: "elegant", + colorScheme: { + primary: "#18181b", + secondary: "#71717a", + background: "#ffffff", + text: "#27272a", + }, + spacing: { + sectionGap: 28, + itemGap: 18, + contentPadding: 32, + }, + basic: { + layout: "center", + }, +}; diff --git a/src/components/templates/elegant/index.tsx b/src/components/templates/elegant/index.tsx new file mode 100644 index 0000000..c27bcf5 --- /dev/null +++ b/src/components/templates/elegant/index.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { ResumeData } from "@/types/resume"; +import { ResumeTemplate } from "@/types/template"; +import BaseInfo from "./sections/BaseInfo"; +import ExperienceSection from "./sections/ExperienceSection"; +import EducationSection from "./sections/EducationSection"; +import ProjectSection from "./sections/ProjectSection"; +import SkillSection from "./sections/SkillSection"; +import CustomSection from "./sections/CustomSection"; + +interface ElegantTemplateProps { + data: ResumeData; + template: ResumeTemplate; +} + +const ElegantTemplate: React.FC = ({ data, template }) => { + const { colorScheme } = template; + const enabledSections = data.menuSections.filter((s) => s.enabled).sort((a, b) => a.order - b.order); + + const renderSection = (sectionId: string) => { + switch (sectionId) { + case "basic": + return ; + case "experience": + return ; + case "education": + return ; + case "skills": + return ; + case "projects": + return ; + default: + if (sectionId in data.customData) { + const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId; + return ; + } + return null; + } + }; + + return ( +
+
+ {enabledSections.map((section) => ( +
{renderSection(section.id)}
+ ))} +
+
+ ); +}; + +export default ElegantTemplate; diff --git a/src/components/templates/elegant/sections/BaseInfo.tsx b/src/components/templates/elegant/sections/BaseInfo.tsx new file mode 100644 index 0000000..e62b10c --- /dev/null +++ b/src/components/templates/elegant/sections/BaseInfo.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { motion } from "framer-motion"; +import * as Icons from "lucide-react"; +import { cn, formatDateString } from "@/lib/utils"; +import { BasicInfo, getBorderRadiusValue, GlobalSettings } from "@/types/resume"; +import { ResumeTemplate } from "@/types/template"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { useTranslations, useLocale } from "@/i18n/compat/client"; +import GithubContribution from "@/components/shared/GithubContribution"; + +interface BaseInfoProps { + basic: BasicInfo | undefined; + globalSettings: GlobalSettings | undefined; + template?: ResumeTemplate; +} + +const BaseInfo = ({ basic = {} as BasicInfo, globalSettings, template }: BaseInfoProps) => { + const t = useTranslations("workbench"); + const locale = useLocale(); + const useIconMode = globalSettings?.useIconMode ?? false; + const layout = basic?.layout || "left"; + + const getIcon = (iconName: string | undefined) => { + const IconComponent = Icons[iconName as keyof typeof Icons] as React.ElementType; + return IconComponent ? : null; + }; + + const getOrderedFields = React.useMemo(() => { + if (!basic.fieldOrder) { + return [{ key: "email", value: basic.email, icon: basic.icons?.email || "Mail", label: "电子邮箱", visible: true, custom: false }] + .filter((item) => Boolean(item.value && item.visible)); + } + return basic.fieldOrder + .filter((field) => field.visible !== false && field.key !== "name" && field.key !== "title") + .map((field) => ({ + key: field.key, value: field.key === "birthDate" && basic[field.key] ? formatDateString(basic[field.key] as string, locale) : (basic[field.key] as string), + icon: basic.icons?.[field.key] || "User", label: field.label, visible: field.visible, custom: field.custom, + })) + .filter((item) => Boolean(item.value)); + }, [basic]); + + const allFields = [ + ...getOrderedFields, + ...(basic.customFields?.filter((field) => field.visible !== false).map((field) => ({ + key: field.id, value: field.value, icon: field.icon, label: field.label, visible: true, custom: true, + })) || []), + ]; + + const nameField = basic.fieldOrder?.find((f) => f.key === "name") || { key: "name", label: "姓名", visible: true }; + const titleField = basic.fieldOrder?.find((f) => f.key === "title") || { key: "title", label: "职位", visible: true }; + + const PhotoComponent = basic.photo && basic.photoConfig?.visible && ( + +
+ {`${basic.name}'s +
+
+ ); + + const layoutStyles = { + left: { container: "flex items-center justify-between gap-6", leftContent: "flex items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-left" }, + right: { container: "flex items-center justify-between gap-6 flex-row-reverse", leftContent: "flex justify-end items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-right" }, + center: { container: "flex flex-col items-center gap-3", leftContent: "flex flex-col items-center gap-4", fields: "w-full flex justify-start items-center flex-wrap gap-3", nameTitle: "text-center" }, + }; + + const styles = layoutStyles[layout as keyof typeof layoutStyles] || layoutStyles.left; + + return ( + +
+
+ {PhotoComponent} +
+ {nameField.visible !== false && basic[nameField.key] && ( + {basic[nameField.key] as string} + )} + {titleField.visible !== false && basic[titleField.key] && ( + {basic[titleField.key] as string} + )} +
+
+ + {allFields.map((item) => ( + + {useIconMode ? ( +
+ {getIcon(item.icon)} + {item.key === "email" ? {item.value} : {item.value}} +
+ ) : ( +
+ {!item.custom && {t(`basicPanel.basicFields.${item.key}`)}:} + {item.custom && {item.label}:} + {item.value} +
+ )} +
+ ))} +
+
+ {basic.githubContributionsVisible && ( + + )} +
+ ); +}; + +export default BaseInfo; diff --git a/src/components/templates/elegant/sections/CustomSection.tsx b/src/components/templates/elegant/sections/CustomSection.tsx new file mode 100644 index 0000000..ff2f970 --- /dev/null +++ b/src/components/templates/elegant/sections/CustomSection.tsx @@ -0,0 +1,58 @@ +import { AnimatePresence, motion } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { GlobalSettings, CustomItem } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface CustomSectionProps { + sectionId: string; + title: string; + items: CustomItem[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const CustomSection = ({ sectionId, title, items, globalSettings, showTitle = true }: CustomSectionProps) => { + const locale = useLocale(); + const visibleItems = items?.filter((item) => item.visible && (item.title || item.description)); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleItems.map((item) => ( + + +
+

{item.title}

+
+ {centerSubtitle && ( + + {item.subtitle} + + )} + + {formatDateString(item.dateRange, locale)} + +
+ {!centerSubtitle && item.subtitle && ( + {item.subtitle} + )} + {item.description && ( + + )} + + ))} +
+
+ ); +}; + +export default CustomSection; diff --git a/src/components/templates/elegant/sections/EducationSection.tsx b/src/components/templates/elegant/sections/EducationSection.tsx new file mode 100644 index 0000000..d49d094 --- /dev/null +++ b/src/components/templates/elegant/sections/EducationSection.tsx @@ -0,0 +1,61 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { Education, GlobalSettings } from "@/types/resume"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { useLocale } from "@/i18n/compat/client"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; + +interface EducationSectionProps { + education?: Education[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const EducationSection = ({ education, globalSettings, showTitle = true }: EducationSectionProps) => { + const locale = useLocale(); + const visibleEducation = education?.filter((edu) => edu.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleEducation?.map((edu) => ( + + +
+ {edu.school} +
+ {centerSubtitle && ( + + {[edu.major, edu.degree].filter(Boolean).join(" · ")} + {edu.gpa && ` · GPA ${edu.gpa}`} + + )} + + {`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`} + +
+ {!centerSubtitle && ( + + {[edu.major, edu.degree].filter(Boolean).join(" · ")} + {edu.gpa && ` · GPA ${edu.gpa}`} + + )} + {edu.description && ( + + )} + + ))} +
+
+ ); +}; + +export default EducationSection; diff --git a/src/components/templates/elegant/sections/ExperienceSection.tsx b/src/components/templates/elegant/sections/ExperienceSection.tsx new file mode 100644 index 0000000..bb4d3ba --- /dev/null +++ b/src/components/templates/elegant/sections/ExperienceSection.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Experience, GlobalSettings } from "@/types/resume"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface ExperienceSectionProps { + experiences?: Experience[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const ExperienceSection: React.FC = ({ experiences, globalSettings, showTitle = true }) => { + const locale = useLocale(); + const visibleExperiences = experiences?.filter((exp) => exp.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleExperiences?.map((exp) => ( + + +
+ {exp.company} +
+ {centerSubtitle && ( + + {exp.position} + + )} +
+ {formatDateString(exp.date, locale)} +
+
+ {exp.position && !centerSubtitle && ( + {exp.position} + )} + {exp.details && ( + + )} + + ))} +
+
+ ); +}; + +export default ExperienceSection; diff --git a/src/components/templates/elegant/sections/ProjectSection.tsx b/src/components/templates/elegant/sections/ProjectSection.tsx new file mode 100644 index 0000000..3607f72 --- /dev/null +++ b/src/components/templates/elegant/sections/ProjectSection.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { Project, GlobalSettings } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface ProjectSectionProps { + projects: Project[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const ProjectSection: React.FC = ({ projects, globalSettings, showTitle = true }) => { + const locale = useLocale(); + const visibleProjects = projects?.filter((p) => p.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + + {visibleProjects.map((project) => ( + + +
+

{project.name}

+
+ {project.link && !centerSubtitle && ( + + {(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()} + + )} + {!project.link && !centerSubtitle && !flexLayout &&
} + {centerSubtitle && ( + + {project.role} + + )} +
+ {formatDateString(project.date, locale)} +
+ + {project.role && !centerSubtitle && ( + {project.role} + )} + {project.link && centerSubtitle && ( + {project.link} + )} + {project.description && ( + + )} + + ))} + + + + ); +}; + +export default ProjectSection; diff --git a/src/components/templates/elegant/sections/SectionTitle.tsx b/src/components/templates/elegant/sections/SectionTitle.tsx new file mode 100644 index 0000000..6d9b51f --- /dev/null +++ b/src/components/templates/elegant/sections/SectionTitle.tsx @@ -0,0 +1,44 @@ +import { useMemo } from "react"; +import { GlobalSettings } from "@/types/resume"; +import { useTemplateContext } from "../../TemplateContext"; +import { useResumeStore } from "@/store/useResumeStore"; + +interface SectionTitleProps { + globalSettings?: GlobalSettings; + type: string; + title?: string; + showTitle?: boolean; +} + +const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => { + const { activeResume } = useResumeStore(); + const templateContext = useTemplateContext(); + const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? []; + + const renderTitle = useMemo(() => { + if (type === "custom") return title; + return menuSections.find((s) => s.id === type)?.title; + }, [menuSections, type, title]); + + const themeColor = globalSettings?.themeColor; + if (!showTitle) return null; + + return ( +
+ + ); +}; + +export default SectionTitle; diff --git a/src/components/templates/elegant/sections/SkillSection.tsx b/src/components/templates/elegant/sections/SkillSection.tsx new file mode 100644 index 0000000..6721caf --- /dev/null +++ b/src/components/templates/elegant/sections/SkillSection.tsx @@ -0,0 +1,27 @@ +import { motion } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { GlobalSettings } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; + +interface SkillSectionProps { + skill?: string; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => { + return ( + + + + + + + ); +}; + +export default SkillSection; diff --git a/src/components/templates/index.tsx b/src/components/templates/index.tsx index 5acceed..494451a 100644 --- a/src/components/templates/index.tsx +++ b/src/components/templates/index.tsx @@ -1,14 +1,6 @@ import React from "react"; -import ClassicTemplate from "./ClassicTemplate"; -import ModernTemplate from "./ModernTemplate"; -import LeftRightTemplate from "./LeftRightTemplate"; -import TimelineTemplate from "./TimelineTemplate"; -import MinimalistTemplate from "./MinimalistTemplate"; - -import ElegantTemplate from "./ElegantTemplate"; -import CreativeTemplate from "./CreativeTemplate"; - import { TemplateProvider } from "./TemplateContext"; +import { getTemplateComponent } from "./registry"; import { ResumeData } from "@/types/resume"; import { ResumeTemplate } from "@/types/template"; @@ -19,32 +11,13 @@ interface TemplateProps { const ResumeTemplateComponent: React.FC = ({ data, - template + template, }) => { - const renderTemplate = () => { - switch (template.layout) { - case "modern": - return ; - case "left-right": - return ; - case "timeline": - return ; - case "minimalist": - return ; - - case "elegant": - return ; - case "creative": - return ; - - default: - return ; - } - }; + const TemplateComponent = getTemplateComponent(template.layout); return ( - {renderTemplate()} + ); }; diff --git a/src/components/templates/left-right/config.ts b/src/components/templates/left-right/config.ts new file mode 100644 index 0000000..1a451f5 --- /dev/null +++ b/src/components/templates/left-right/config.ts @@ -0,0 +1,23 @@ +import { ResumeTemplate } from "@/types/template"; + +export const leftRightConfig: ResumeTemplate = { + id: "left-right", + name: "模块标题背景色", + description: "模块标题背景鲜明,突出美观特色", + thumbnail: "leftRight", + layout: "left-right", + colorScheme: { + primary: "#000000", + secondary: "#9ca3af", + background: "#ffffff", + text: "#212529", + }, + spacing: { + sectionGap: 24, + itemGap: 16, + contentPadding: 32, + }, + basic: { + layout: "left", + }, +}; diff --git a/src/components/templates/left-right/index.tsx b/src/components/templates/left-right/index.tsx new file mode 100644 index 0000000..ab2529e --- /dev/null +++ b/src/components/templates/left-right/index.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { ResumeData } from "@/types/resume"; +import { ResumeTemplate } from "@/types/template"; +import BaseInfo from "./sections/BaseInfo"; +import ExperienceSection from "./sections/ExperienceSection"; +import EducationSection from "./sections/EducationSection"; +import ProjectSection from "./sections/ProjectSection"; +import SkillSection from "./sections/SkillSection"; +import CustomSection from "./sections/CustomSection"; + +interface LeftRightTemplateProps { + data: ResumeData; + template: ResumeTemplate; +} + +const LeftRightTemplate: React.FC = ({ data, template }) => { + const { colorScheme } = template; + const enabledSections = data.menuSections.filter((s) => s.enabled).sort((a, b) => a.order - b.order); + + const renderSection = (sectionId: string) => { + switch (sectionId) { + case "basic": + return ; + case "experience": + return ; + case "education": + return ; + case "skills": + return ; + case "projects": + return ; + default: + if (sectionId in data.customData) { + const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId; + return ; + } + return null; + } + }; + + return ( +
+ {enabledSections.map((section) => ( +
{renderSection(section.id)}
+ ))} +
+ ); +}; + +export default LeftRightTemplate; diff --git a/src/components/templates/left-right/sections/BaseInfo.tsx b/src/components/templates/left-right/sections/BaseInfo.tsx new file mode 100644 index 0000000..e62b10c --- /dev/null +++ b/src/components/templates/left-right/sections/BaseInfo.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { motion } from "framer-motion"; +import * as Icons from "lucide-react"; +import { cn, formatDateString } from "@/lib/utils"; +import { BasicInfo, getBorderRadiusValue, GlobalSettings } from "@/types/resume"; +import { ResumeTemplate } from "@/types/template"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { useTranslations, useLocale } from "@/i18n/compat/client"; +import GithubContribution from "@/components/shared/GithubContribution"; + +interface BaseInfoProps { + basic: BasicInfo | undefined; + globalSettings: GlobalSettings | undefined; + template?: ResumeTemplate; +} + +const BaseInfo = ({ basic = {} as BasicInfo, globalSettings, template }: BaseInfoProps) => { + const t = useTranslations("workbench"); + const locale = useLocale(); + const useIconMode = globalSettings?.useIconMode ?? false; + const layout = basic?.layout || "left"; + + const getIcon = (iconName: string | undefined) => { + const IconComponent = Icons[iconName as keyof typeof Icons] as React.ElementType; + return IconComponent ? : null; + }; + + const getOrderedFields = React.useMemo(() => { + if (!basic.fieldOrder) { + return [{ key: "email", value: basic.email, icon: basic.icons?.email || "Mail", label: "电子邮箱", visible: true, custom: false }] + .filter((item) => Boolean(item.value && item.visible)); + } + return basic.fieldOrder + .filter((field) => field.visible !== false && field.key !== "name" && field.key !== "title") + .map((field) => ({ + key: field.key, value: field.key === "birthDate" && basic[field.key] ? formatDateString(basic[field.key] as string, locale) : (basic[field.key] as string), + icon: basic.icons?.[field.key] || "User", label: field.label, visible: field.visible, custom: field.custom, + })) + .filter((item) => Boolean(item.value)); + }, [basic]); + + const allFields = [ + ...getOrderedFields, + ...(basic.customFields?.filter((field) => field.visible !== false).map((field) => ({ + key: field.id, value: field.value, icon: field.icon, label: field.label, visible: true, custom: true, + })) || []), + ]; + + const nameField = basic.fieldOrder?.find((f) => f.key === "name") || { key: "name", label: "姓名", visible: true }; + const titleField = basic.fieldOrder?.find((f) => f.key === "title") || { key: "title", label: "职位", visible: true }; + + const PhotoComponent = basic.photo && basic.photoConfig?.visible && ( + +
+ {`${basic.name}'s +
+
+ ); + + const layoutStyles = { + left: { container: "flex items-center justify-between gap-6", leftContent: "flex items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-left" }, + right: { container: "flex items-center justify-between gap-6 flex-row-reverse", leftContent: "flex justify-end items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-right" }, + center: { container: "flex flex-col items-center gap-3", leftContent: "flex flex-col items-center gap-4", fields: "w-full flex justify-start items-center flex-wrap gap-3", nameTitle: "text-center" }, + }; + + const styles = layoutStyles[layout as keyof typeof layoutStyles] || layoutStyles.left; + + return ( + +
+
+ {PhotoComponent} +
+ {nameField.visible !== false && basic[nameField.key] && ( + {basic[nameField.key] as string} + )} + {titleField.visible !== false && basic[titleField.key] && ( + {basic[titleField.key] as string} + )} +
+
+ + {allFields.map((item) => ( + + {useIconMode ? ( +
+ {getIcon(item.icon)} + {item.key === "email" ? {item.value} : {item.value}} +
+ ) : ( +
+ {!item.custom && {t(`basicPanel.basicFields.${item.key}`)}:} + {item.custom && {item.label}:} + {item.value} +
+ )} +
+ ))} +
+
+ {basic.githubContributionsVisible && ( + + )} +
+ ); +}; + +export default BaseInfo; diff --git a/src/components/templates/left-right/sections/CustomSection.tsx b/src/components/templates/left-right/sections/CustomSection.tsx new file mode 100644 index 0000000..ff2f970 --- /dev/null +++ b/src/components/templates/left-right/sections/CustomSection.tsx @@ -0,0 +1,58 @@ +import { AnimatePresence, motion } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { GlobalSettings, CustomItem } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface CustomSectionProps { + sectionId: string; + title: string; + items: CustomItem[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const CustomSection = ({ sectionId, title, items, globalSettings, showTitle = true }: CustomSectionProps) => { + const locale = useLocale(); + const visibleItems = items?.filter((item) => item.visible && (item.title || item.description)); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleItems.map((item) => ( + + +
+

{item.title}

+
+ {centerSubtitle && ( + + {item.subtitle} + + )} + + {formatDateString(item.dateRange, locale)} + +
+ {!centerSubtitle && item.subtitle && ( + {item.subtitle} + )} + {item.description && ( + + )} + + ))} +
+
+ ); +}; + +export default CustomSection; diff --git a/src/components/templates/left-right/sections/EducationSection.tsx b/src/components/templates/left-right/sections/EducationSection.tsx new file mode 100644 index 0000000..d49d094 --- /dev/null +++ b/src/components/templates/left-right/sections/EducationSection.tsx @@ -0,0 +1,61 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { Education, GlobalSettings } from "@/types/resume"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { useLocale } from "@/i18n/compat/client"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; + +interface EducationSectionProps { + education?: Education[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const EducationSection = ({ education, globalSettings, showTitle = true }: EducationSectionProps) => { + const locale = useLocale(); + const visibleEducation = education?.filter((edu) => edu.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleEducation?.map((edu) => ( + + +
+ {edu.school} +
+ {centerSubtitle && ( + + {[edu.major, edu.degree].filter(Boolean).join(" · ")} + {edu.gpa && ` · GPA ${edu.gpa}`} + + )} + + {`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`} + +
+ {!centerSubtitle && ( + + {[edu.major, edu.degree].filter(Boolean).join(" · ")} + {edu.gpa && ` · GPA ${edu.gpa}`} + + )} + {edu.description && ( + + )} + + ))} +
+
+ ); +}; + +export default EducationSection; diff --git a/src/components/templates/left-right/sections/ExperienceSection.tsx b/src/components/templates/left-right/sections/ExperienceSection.tsx new file mode 100644 index 0000000..bb4d3ba --- /dev/null +++ b/src/components/templates/left-right/sections/ExperienceSection.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Experience, GlobalSettings } from "@/types/resume"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface ExperienceSectionProps { + experiences?: Experience[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const ExperienceSection: React.FC = ({ experiences, globalSettings, showTitle = true }) => { + const locale = useLocale(); + const visibleExperiences = experiences?.filter((exp) => exp.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleExperiences?.map((exp) => ( + + +
+ {exp.company} +
+ {centerSubtitle && ( + + {exp.position} + + )} +
+ {formatDateString(exp.date, locale)} +
+
+ {exp.position && !centerSubtitle && ( + {exp.position} + )} + {exp.details && ( + + )} + + ))} +
+
+ ); +}; + +export default ExperienceSection; diff --git a/src/components/templates/left-right/sections/ProjectSection.tsx b/src/components/templates/left-right/sections/ProjectSection.tsx new file mode 100644 index 0000000..3607f72 --- /dev/null +++ b/src/components/templates/left-right/sections/ProjectSection.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { Project, GlobalSettings } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface ProjectSectionProps { + projects: Project[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const ProjectSection: React.FC = ({ projects, globalSettings, showTitle = true }) => { + const locale = useLocale(); + const visibleProjects = projects?.filter((p) => p.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + + {visibleProjects.map((project) => ( + + +
+

{project.name}

+
+ {project.link && !centerSubtitle && ( + + {(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()} + + )} + {!project.link && !centerSubtitle && !flexLayout &&
} + {centerSubtitle && ( + + {project.role} + + )} +
+ {formatDateString(project.date, locale)} +
+ + {project.role && !centerSubtitle && ( + {project.role} + )} + {project.link && centerSubtitle && ( + {project.link} + )} + {project.description && ( + + )} + + ))} + + + + ); +}; + +export default ProjectSection; diff --git a/src/components/templates/left-right/sections/SectionTitle.tsx b/src/components/templates/left-right/sections/SectionTitle.tsx new file mode 100644 index 0000000..c171f0b --- /dev/null +++ b/src/components/templates/left-right/sections/SectionTitle.tsx @@ -0,0 +1,44 @@ +import { useMemo } from "react"; +import { GlobalSettings } from "@/types/resume"; +import { useTemplateContext } from "../../TemplateContext"; +import { useResumeStore } from "@/store/useResumeStore"; + +interface SectionTitleProps { + globalSettings?: GlobalSettings; + type: string; + title?: string; + showTitle?: boolean; +} + +const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => { + const { activeResume } = useResumeStore(); + const templateContext = useTemplateContext(); + const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? []; + + const renderTitle = useMemo(() => { + if (type === "custom") return title; + return menuSections.find((s) => s.id === type)?.title; + }, [menuSections, type, title]); + + const themeColor = globalSettings?.themeColor; + if (!showTitle) return null; + + return ( +
+
+

+ {renderTitle} +

+
+ ); +}; + +export default SectionTitle; diff --git a/src/components/templates/left-right/sections/SkillSection.tsx b/src/components/templates/left-right/sections/SkillSection.tsx new file mode 100644 index 0000000..6721caf --- /dev/null +++ b/src/components/templates/left-right/sections/SkillSection.tsx @@ -0,0 +1,27 @@ +import { motion } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { GlobalSettings } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; + +interface SkillSectionProps { + skill?: string; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => { + return ( + + + + + + + ); +}; + +export default SkillSection; diff --git a/src/components/templates/minimalist/config.ts b/src/components/templates/minimalist/config.ts new file mode 100644 index 0000000..c025eee --- /dev/null +++ b/src/components/templates/minimalist/config.ts @@ -0,0 +1,23 @@ +import { ResumeTemplate } from "@/types/template"; + +export const minimalistConfig: ResumeTemplate = { + id: "minimalist", + name: "极简模板", + description: "大面积留白,干净纯粹的排版风格", + thumbnail: "minimalist", + layout: "minimalist", + colorScheme: { + primary: "#171717", + secondary: "#737373", + background: "#ffffff", + text: "#171717", + }, + spacing: { + sectionGap: 32, + itemGap: 24, + contentPadding: 40, + }, + basic: { + layout: "center", + }, +}; diff --git a/src/components/templates/minimalist/index.tsx b/src/components/templates/minimalist/index.tsx new file mode 100644 index 0000000..9284fe5 --- /dev/null +++ b/src/components/templates/minimalist/index.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { ResumeData } from "@/types/resume"; +import { ResumeTemplate } from "@/types/template"; +import BaseInfo from "./sections/BaseInfo"; +import ExperienceSection from "./sections/ExperienceSection"; +import EducationSection from "./sections/EducationSection"; +import ProjectSection from "./sections/ProjectSection"; +import SkillSection from "./sections/SkillSection"; +import CustomSection from "./sections/CustomSection"; + +interface MinimalistTemplateProps { + data: ResumeData; + template: ResumeTemplate; +} + +const MinimalistTemplate: React.FC = ({ data, template }) => { + const { colorScheme } = template; + const enabledSections = data.menuSections.filter((s) => s.enabled).sort((a, b) => a.order - b.order); + + const renderSection = (sectionId: string) => { + switch (sectionId) { + case "basic": + return ; + case "experience": + return ; + case "education": + return ; + case "skills": + return ; + case "projects": + return ; + default: + if (sectionId in data.customData) { + const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId; + return ; + } + return null; + } + }; + + return ( +
+ {enabledSections.map((section) => ( +
{renderSection(section.id)}
+ ))} +
+ ); +}; + +export default MinimalistTemplate; diff --git a/src/components/templates/minimalist/sections/BaseInfo.tsx b/src/components/templates/minimalist/sections/BaseInfo.tsx new file mode 100644 index 0000000..e62b10c --- /dev/null +++ b/src/components/templates/minimalist/sections/BaseInfo.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { motion } from "framer-motion"; +import * as Icons from "lucide-react"; +import { cn, formatDateString } from "@/lib/utils"; +import { BasicInfo, getBorderRadiusValue, GlobalSettings } from "@/types/resume"; +import { ResumeTemplate } from "@/types/template"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { useTranslations, useLocale } from "@/i18n/compat/client"; +import GithubContribution from "@/components/shared/GithubContribution"; + +interface BaseInfoProps { + basic: BasicInfo | undefined; + globalSettings: GlobalSettings | undefined; + template?: ResumeTemplate; +} + +const BaseInfo = ({ basic = {} as BasicInfo, globalSettings, template }: BaseInfoProps) => { + const t = useTranslations("workbench"); + const locale = useLocale(); + const useIconMode = globalSettings?.useIconMode ?? false; + const layout = basic?.layout || "left"; + + const getIcon = (iconName: string | undefined) => { + const IconComponent = Icons[iconName as keyof typeof Icons] as React.ElementType; + return IconComponent ? : null; + }; + + const getOrderedFields = React.useMemo(() => { + if (!basic.fieldOrder) { + return [{ key: "email", value: basic.email, icon: basic.icons?.email || "Mail", label: "电子邮箱", visible: true, custom: false }] + .filter((item) => Boolean(item.value && item.visible)); + } + return basic.fieldOrder + .filter((field) => field.visible !== false && field.key !== "name" && field.key !== "title") + .map((field) => ({ + key: field.key, value: field.key === "birthDate" && basic[field.key] ? formatDateString(basic[field.key] as string, locale) : (basic[field.key] as string), + icon: basic.icons?.[field.key] || "User", label: field.label, visible: field.visible, custom: field.custom, + })) + .filter((item) => Boolean(item.value)); + }, [basic]); + + const allFields = [ + ...getOrderedFields, + ...(basic.customFields?.filter((field) => field.visible !== false).map((field) => ({ + key: field.id, value: field.value, icon: field.icon, label: field.label, visible: true, custom: true, + })) || []), + ]; + + const nameField = basic.fieldOrder?.find((f) => f.key === "name") || { key: "name", label: "姓名", visible: true }; + const titleField = basic.fieldOrder?.find((f) => f.key === "title") || { key: "title", label: "职位", visible: true }; + + const PhotoComponent = basic.photo && basic.photoConfig?.visible && ( + +
+ {`${basic.name}'s +
+
+ ); + + const layoutStyles = { + left: { container: "flex items-center justify-between gap-6", leftContent: "flex items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-left" }, + right: { container: "flex items-center justify-between gap-6 flex-row-reverse", leftContent: "flex justify-end items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-right" }, + center: { container: "flex flex-col items-center gap-3", leftContent: "flex flex-col items-center gap-4", fields: "w-full flex justify-start items-center flex-wrap gap-3", nameTitle: "text-center" }, + }; + + const styles = layoutStyles[layout as keyof typeof layoutStyles] || layoutStyles.left; + + return ( + +
+
+ {PhotoComponent} +
+ {nameField.visible !== false && basic[nameField.key] && ( + {basic[nameField.key] as string} + )} + {titleField.visible !== false && basic[titleField.key] && ( + {basic[titleField.key] as string} + )} +
+
+ + {allFields.map((item) => ( + + {useIconMode ? ( +
+ {getIcon(item.icon)} + {item.key === "email" ? {item.value} : {item.value}} +
+ ) : ( +
+ {!item.custom && {t(`basicPanel.basicFields.${item.key}`)}:} + {item.custom && {item.label}:} + {item.value} +
+ )} +
+ ))} +
+
+ {basic.githubContributionsVisible && ( + + )} +
+ ); +}; + +export default BaseInfo; diff --git a/src/components/templates/minimalist/sections/CustomSection.tsx b/src/components/templates/minimalist/sections/CustomSection.tsx new file mode 100644 index 0000000..ff2f970 --- /dev/null +++ b/src/components/templates/minimalist/sections/CustomSection.tsx @@ -0,0 +1,58 @@ +import { AnimatePresence, motion } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { GlobalSettings, CustomItem } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface CustomSectionProps { + sectionId: string; + title: string; + items: CustomItem[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const CustomSection = ({ sectionId, title, items, globalSettings, showTitle = true }: CustomSectionProps) => { + const locale = useLocale(); + const visibleItems = items?.filter((item) => item.visible && (item.title || item.description)); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleItems.map((item) => ( + + +
+

{item.title}

+
+ {centerSubtitle && ( + + {item.subtitle} + + )} + + {formatDateString(item.dateRange, locale)} + +
+ {!centerSubtitle && item.subtitle && ( + {item.subtitle} + )} + {item.description && ( + + )} + + ))} +
+
+ ); +}; + +export default CustomSection; diff --git a/src/components/templates/minimalist/sections/EducationSection.tsx b/src/components/templates/minimalist/sections/EducationSection.tsx new file mode 100644 index 0000000..d49d094 --- /dev/null +++ b/src/components/templates/minimalist/sections/EducationSection.tsx @@ -0,0 +1,61 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { Education, GlobalSettings } from "@/types/resume"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { useLocale } from "@/i18n/compat/client"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; + +interface EducationSectionProps { + education?: Education[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const EducationSection = ({ education, globalSettings, showTitle = true }: EducationSectionProps) => { + const locale = useLocale(); + const visibleEducation = education?.filter((edu) => edu.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleEducation?.map((edu) => ( + + +
+ {edu.school} +
+ {centerSubtitle && ( + + {[edu.major, edu.degree].filter(Boolean).join(" · ")} + {edu.gpa && ` · GPA ${edu.gpa}`} + + )} + + {`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`} + +
+ {!centerSubtitle && ( + + {[edu.major, edu.degree].filter(Boolean).join(" · ")} + {edu.gpa && ` · GPA ${edu.gpa}`} + + )} + {edu.description && ( + + )} + + ))} +
+
+ ); +}; + +export default EducationSection; diff --git a/src/components/templates/minimalist/sections/ExperienceSection.tsx b/src/components/templates/minimalist/sections/ExperienceSection.tsx new file mode 100644 index 0000000..bb4d3ba --- /dev/null +++ b/src/components/templates/minimalist/sections/ExperienceSection.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Experience, GlobalSettings } from "@/types/resume"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface ExperienceSectionProps { + experiences?: Experience[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const ExperienceSection: React.FC = ({ experiences, globalSettings, showTitle = true }) => { + const locale = useLocale(); + const visibleExperiences = experiences?.filter((exp) => exp.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleExperiences?.map((exp) => ( + + +
+ {exp.company} +
+ {centerSubtitle && ( + + {exp.position} + + )} +
+ {formatDateString(exp.date, locale)} +
+
+ {exp.position && !centerSubtitle && ( + {exp.position} + )} + {exp.details && ( + + )} + + ))} +
+
+ ); +}; + +export default ExperienceSection; diff --git a/src/components/templates/minimalist/sections/ProjectSection.tsx b/src/components/templates/minimalist/sections/ProjectSection.tsx new file mode 100644 index 0000000..3607f72 --- /dev/null +++ b/src/components/templates/minimalist/sections/ProjectSection.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { Project, GlobalSettings } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface ProjectSectionProps { + projects: Project[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const ProjectSection: React.FC = ({ projects, globalSettings, showTitle = true }) => { + const locale = useLocale(); + const visibleProjects = projects?.filter((p) => p.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + + {visibleProjects.map((project) => ( + + +
+

{project.name}

+
+ {project.link && !centerSubtitle && ( + + {(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()} + + )} + {!project.link && !centerSubtitle && !flexLayout &&
} + {centerSubtitle && ( + + {project.role} + + )} +
+ {formatDateString(project.date, locale)} +
+ + {project.role && !centerSubtitle && ( + {project.role} + )} + {project.link && centerSubtitle && ( + {project.link} + )} + {project.description && ( + + )} + + ))} + + + + ); +}; + +export default ProjectSection; diff --git a/src/components/templates/minimalist/sections/SectionTitle.tsx b/src/components/templates/minimalist/sections/SectionTitle.tsx new file mode 100644 index 0000000..6addecf --- /dev/null +++ b/src/components/templates/minimalist/sections/SectionTitle.tsx @@ -0,0 +1,40 @@ +import { useMemo } from "react"; +import { GlobalSettings } from "@/types/resume"; +import { useTemplateContext } from "../../TemplateContext"; +import { useResumeStore } from "@/store/useResumeStore"; + +interface SectionTitleProps { + globalSettings?: GlobalSettings; + type: string; + title?: string; + showTitle?: boolean; +} + +const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => { + const { activeResume } = useResumeStore(); + const templateContext = useTemplateContext(); + const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? []; + + const renderTitle = useMemo(() => { + if (type === "custom") return title; + return menuSections.find((s) => s.id === type)?.title; + }, [menuSections, type, title]); + + const themeColor = globalSettings?.themeColor; + if (!showTitle) return null; + + return ( +

+ {renderTitle} +

+ ); +}; + +export default SectionTitle; diff --git a/src/components/templates/minimalist/sections/SkillSection.tsx b/src/components/templates/minimalist/sections/SkillSection.tsx new file mode 100644 index 0000000..6721caf --- /dev/null +++ b/src/components/templates/minimalist/sections/SkillSection.tsx @@ -0,0 +1,27 @@ +import { motion } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { GlobalSettings } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; + +interface SkillSectionProps { + skill?: string; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => { + return ( + + + + + + + ); +}; + +export default SkillSection; diff --git a/src/components/templates/modern/config.ts b/src/components/templates/modern/config.ts new file mode 100644 index 0000000..42f349c --- /dev/null +++ b/src/components/templates/modern/config.ts @@ -0,0 +1,23 @@ +import { ResumeTemplate } from "@/types/template"; + +export const modernConfig: ResumeTemplate = { + id: "modern", + name: "两栏布局", + description: "经典两栏,突出个人特色", + thumbnail: "modern", + layout: "modern", + colorScheme: { + primary: "#000000", + secondary: "#6b7280", + background: "#ffffff", + text: "#212529", + }, + spacing: { + sectionGap: 20, + itemGap: 20, + contentPadding: 1, + }, + basic: { + layout: "center", + }, +}; diff --git a/src/components/templates/modern/index.tsx b/src/components/templates/modern/index.tsx new file mode 100644 index 0000000..d4f4c7e --- /dev/null +++ b/src/components/templates/modern/index.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { ResumeData } from "@/types/resume"; +import { ResumeTemplate } from "@/types/template"; +import BaseInfo from "./sections/BaseInfo"; +import ExperienceSection from "./sections/ExperienceSection"; +import EducationSection from "./sections/EducationSection"; +import ProjectSection from "./sections/ProjectSection"; +import SkillSection from "./sections/SkillSection"; +import CustomSection from "./sections/CustomSection"; + +interface ModernTemplateProps { + data: ResumeData; + template: ResumeTemplate; +} + +const ModernTemplate: React.FC = ({ data, template }) => { + const { colorScheme } = template; + const enabledSections = data.menuSections.filter((s) => s.enabled).sort((a, b) => a.order - b.order); + + const renderSection = (sectionId: string) => { + switch (sectionId) { + case "basic": + return ; + case "experience": + return ; + case "education": + return ; + case "skills": + return ; + case "projects": + return ; + default: + if (sectionId in data.customData) { + const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId; + return ; + } + return null; + } + }; + + const basicSection = enabledSections.find((s) => s.id === "basic"); + const otherSections = enabledSections.filter((s) => s.id !== "basic"); + + return ( +
+
+ {basicSection && renderSection(basicSection.id)} +
+
+ {otherSections.map((section) => ( +
{renderSection(section.id)}
+ ))} +
+
+ ); +}; + +export default ModernTemplate; diff --git a/src/components/templates/modern/sections/BaseInfo.tsx b/src/components/templates/modern/sections/BaseInfo.tsx new file mode 100644 index 0000000..e57c972 --- /dev/null +++ b/src/components/templates/modern/sections/BaseInfo.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { motion } from "framer-motion"; +import * as Icons from "lucide-react"; +import { cn, formatDateString } from "@/lib/utils"; +import { BasicInfo, getBorderRadiusValue, GlobalSettings } from "@/types/resume"; +import { ResumeTemplate } from "@/types/template"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { useTranslations, useLocale } from "@/i18n/compat/client"; + +interface BaseInfoProps { + basic: BasicInfo | undefined; + globalSettings: GlobalSettings | undefined; + template?: ResumeTemplate; +} + +/** + * Modern template BaseInfo — designed for sidebar (white text on theme color background). + */ +const BaseInfo = ({ basic = {} as BasicInfo, globalSettings, template }: BaseInfoProps) => { + const t = useTranslations("workbench"); + const locale = useLocale(); + const useIconMode = globalSettings?.useIconMode ?? false; + + const getIcon = (iconName: string | undefined) => { + const IconComponent = Icons[iconName as keyof typeof Icons] as React.ElementType; + return IconComponent ? : null; + }; + + const getOrderedFields = React.useMemo(() => { + if (!basic.fieldOrder) { + return [{ key: "email", value: basic.email, icon: basic.icons?.email || "Mail", label: "电子邮箱", visible: true, custom: false }] + .filter((item) => Boolean(item.value && item.visible)); + } + return basic.fieldOrder + .filter((field) => field.visible !== false && field.key !== "name" && field.key !== "title") + .map((field) => ({ + key: field.key, value: field.key === "birthDate" && basic[field.key] ? formatDateString(basic[field.key] as string, locale) : (basic[field.key] as string), + icon: basic.icons?.[field.key] || "User", label: field.label, visible: field.visible, custom: field.custom, + })) + .filter((item) => Boolean(item.value)); + }, [basic]); + + const allFields = [ + ...getOrderedFields, + ...(basic.customFields?.filter((field) => field.visible !== false).map((field) => ({ + key: field.id, value: field.value, icon: field.icon, label: field.label, visible: true, custom: true, + })) || []), + ]; + + const nameField = basic.fieldOrder?.find((f) => f.key === "name") || { key: "name", visible: true }; + const titleField = basic.fieldOrder?.find((f) => f.key === "title") || { key: "title", visible: true }; + + const PhotoComponent = basic.photo && basic.photoConfig?.visible && ( + +
+ {`${basic.name}'s +
+
+ ); + + return ( + +
+
+ {PhotoComponent} +
+ {nameField.visible !== false && basic[nameField.key] && ( + {basic[nameField.key] as string} + )} + {titleField.visible !== false && basic[titleField.key] && ( + {basic[titleField.key] as string} + )} +
+
+ + {allFields.map((item) => ( + + {useIconMode ? ( +
+ {getIcon(item.icon)} + {item.key === "email" ? {item.value} : {item.value}} +
+ ) : ( +
+ {!item.custom && {t(`basicPanel.basicFields.${item.key}`)}:} + {item.custom && {item.label}:} + {item.value} +
+ )} +
+ ))} +
+
+
+ ); +}; + +export default BaseInfo; diff --git a/src/components/templates/modern/sections/CustomSection.tsx b/src/components/templates/modern/sections/CustomSection.tsx new file mode 100644 index 0000000..ff2f970 --- /dev/null +++ b/src/components/templates/modern/sections/CustomSection.tsx @@ -0,0 +1,58 @@ +import { AnimatePresence, motion } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { GlobalSettings, CustomItem } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface CustomSectionProps { + sectionId: string; + title: string; + items: CustomItem[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const CustomSection = ({ sectionId, title, items, globalSettings, showTitle = true }: CustomSectionProps) => { + const locale = useLocale(); + const visibleItems = items?.filter((item) => item.visible && (item.title || item.description)); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleItems.map((item) => ( + + +
+

{item.title}

+
+ {centerSubtitle && ( + + {item.subtitle} + + )} + + {formatDateString(item.dateRange, locale)} + +
+ {!centerSubtitle && item.subtitle && ( + {item.subtitle} + )} + {item.description && ( + + )} + + ))} +
+
+ ); +}; + +export default CustomSection; diff --git a/src/components/templates/modern/sections/EducationSection.tsx b/src/components/templates/modern/sections/EducationSection.tsx new file mode 100644 index 0000000..d49d094 --- /dev/null +++ b/src/components/templates/modern/sections/EducationSection.tsx @@ -0,0 +1,61 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { Education, GlobalSettings } from "@/types/resume"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { useLocale } from "@/i18n/compat/client"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; + +interface EducationSectionProps { + education?: Education[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const EducationSection = ({ education, globalSettings, showTitle = true }: EducationSectionProps) => { + const locale = useLocale(); + const visibleEducation = education?.filter((edu) => edu.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleEducation?.map((edu) => ( + + +
+ {edu.school} +
+ {centerSubtitle && ( + + {[edu.major, edu.degree].filter(Boolean).join(" · ")} + {edu.gpa && ` · GPA ${edu.gpa}`} + + )} + + {`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`} + +
+ {!centerSubtitle && ( + + {[edu.major, edu.degree].filter(Boolean).join(" · ")} + {edu.gpa && ` · GPA ${edu.gpa}`} + + )} + {edu.description && ( + + )} + + ))} +
+
+ ); +}; + +export default EducationSection; diff --git a/src/components/templates/modern/sections/ExperienceSection.tsx b/src/components/templates/modern/sections/ExperienceSection.tsx new file mode 100644 index 0000000..bb4d3ba --- /dev/null +++ b/src/components/templates/modern/sections/ExperienceSection.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Experience, GlobalSettings } from "@/types/resume"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface ExperienceSectionProps { + experiences?: Experience[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const ExperienceSection: React.FC = ({ experiences, globalSettings, showTitle = true }) => { + const locale = useLocale(); + const visibleExperiences = experiences?.filter((exp) => exp.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleExperiences?.map((exp) => ( + + +
+ {exp.company} +
+ {centerSubtitle && ( + + {exp.position} + + )} +
+ {formatDateString(exp.date, locale)} +
+
+ {exp.position && !centerSubtitle && ( + {exp.position} + )} + {exp.details && ( + + )} + + ))} +
+
+ ); +}; + +export default ExperienceSection; diff --git a/src/components/templates/modern/sections/ProjectSection.tsx b/src/components/templates/modern/sections/ProjectSection.tsx new file mode 100644 index 0000000..3607f72 --- /dev/null +++ b/src/components/templates/modern/sections/ProjectSection.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { Project, GlobalSettings } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface ProjectSectionProps { + projects: Project[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const ProjectSection: React.FC = ({ projects, globalSettings, showTitle = true }) => { + const locale = useLocale(); + const visibleProjects = projects?.filter((p) => p.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + + {visibleProjects.map((project) => ( + + +
+

{project.name}

+
+ {project.link && !centerSubtitle && ( + + {(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()} + + )} + {!project.link && !centerSubtitle && !flexLayout &&
} + {centerSubtitle && ( + + {project.role} + + )} +
+ {formatDateString(project.date, locale)} +
+ + {project.role && !centerSubtitle && ( + {project.role} + )} + {project.link && centerSubtitle && ( + {project.link} + )} + {project.description && ( + + )} + + ))} + + + + ); +}; + +export default ProjectSection; diff --git a/src/components/templates/modern/sections/SectionTitle.tsx b/src/components/templates/modern/sections/SectionTitle.tsx new file mode 100644 index 0000000..154fd52 --- /dev/null +++ b/src/components/templates/modern/sections/SectionTitle.tsx @@ -0,0 +1,42 @@ +import { useMemo } from "react"; +import { GlobalSettings } from "@/types/resume"; +import { useTemplateContext } from "../../TemplateContext"; +import { useResumeStore } from "@/store/useResumeStore"; + +interface SectionTitleProps { + globalSettings?: GlobalSettings; + type: string; + title?: string; + showTitle?: boolean; +} + +const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => { + const { activeResume } = useResumeStore(); + const templateContext = useTemplateContext(); + const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? []; + + const renderTitle = useMemo(() => { + if (type === "custom") return title; + return menuSections.find((s) => s.id === type)?.title; + }, [menuSections, type, title]); + + const themeColor = globalSettings?.themeColor; + if (!showTitle) return null; + + return ( +

+ {renderTitle} +

+ ); +}; + +export default SectionTitle; diff --git a/src/components/templates/modern/sections/SkillSection.tsx b/src/components/templates/modern/sections/SkillSection.tsx new file mode 100644 index 0000000..6721caf --- /dev/null +++ b/src/components/templates/modern/sections/SkillSection.tsx @@ -0,0 +1,27 @@ +import { motion } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { GlobalSettings } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; + +interface SkillSectionProps { + skill?: string; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => { + return ( + + + + + + + ); +}; + +export default SkillSection; diff --git a/src/components/templates/registry.ts b/src/components/templates/registry.ts new file mode 100644 index 0000000..c5526cd --- /dev/null +++ b/src/components/templates/registry.ts @@ -0,0 +1,55 @@ +import React from "react"; +import { ResumeTemplate } from "@/types/template"; + +// Import configs +import { classicConfig } from "./classic/config"; +import { modernConfig } from "./modern/config"; +import { leftRightConfig } from "./left-right/config"; +import { timelineConfig } from "./timeline/config"; +import { minimalistConfig } from "./minimalist/config"; +import { elegantConfig } from "./elegant/config"; +import { creativeConfig } from "./creative/config"; + +// Import components +import ClassicTemplate from "./classic"; +import ModernTemplate from "./modern"; +import LeftRightTemplate from "./left-right"; +import TimelineTemplate from "./timeline"; +import MinimalistTemplate from "./minimalist"; +import ElegantTemplate from "./elegant"; +import CreativeTemplate from "./creative"; + +export interface TemplateRegistryEntry { + config: ResumeTemplate; + Component: React.FC<{ data: any; template: ResumeTemplate }>; +} + +/** + * Unified template registry. + * To add a new template, create a directory under `templates/` with config.ts + index.tsx, + * then add one line here. No other files need to change. + */ +export const TEMPLATE_REGISTRY: TemplateRegistryEntry[] = [ + { config: classicConfig, Component: ClassicTemplate }, + { config: modernConfig, Component: ModernTemplate }, + { config: leftRightConfig, Component: LeftRightTemplate }, + { config: timelineConfig, Component: TimelineTemplate }, + { config: minimalistConfig, Component: MinimalistTemplate }, + { config: elegantConfig, Component: ElegantTemplate }, + { config: creativeConfig, Component: CreativeTemplate }, +]; + +/** All template configs — drop-in replacement for the old DEFAULT_TEMPLATES */ +export const DEFAULT_TEMPLATES: ResumeTemplate[] = TEMPLATE_REGISTRY.map( + (entry) => entry.config +); + +/** Look up a template component by layout id */ +export function getTemplateComponent( + layout: string +): React.FC<{ data: any; template: ResumeTemplate }> { + return ( + TEMPLATE_REGISTRY.find((entry) => entry.config.layout === layout) + ?.Component ?? ClassicTemplate + ); +} diff --git a/src/components/templates/shared/SectionWrapper.tsx b/src/components/templates/shared/SectionWrapper.tsx new file mode 100644 index 0000000..d587ab3 --- /dev/null +++ b/src/components/templates/shared/SectionWrapper.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { motion } from "framer-motion"; +import { useResumeStore } from "@/store/useResumeStore"; + +interface SectionWrapperProps { + sectionId: string; + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; +} + +/** + * Thin interaction wrapper for all section components. + * Provides hover highlight + click-to-select behavior. + */ +const SectionWrapper: React.FC = ({ + sectionId, + children, + className = "", + style, +}) => { + const { setActiveSection } = useResumeStore(); + + return ( + setActiveSection(sectionId)} + > + {children} + + ); +}; + +export default SectionWrapper; diff --git a/src/components/templates/timeline/config.ts b/src/components/templates/timeline/config.ts new file mode 100644 index 0000000..364c9f2 --- /dev/null +++ b/src/components/templates/timeline/config.ts @@ -0,0 +1,23 @@ +import { ResumeTemplate } from "@/types/template"; + +export const timelineConfig: ResumeTemplate = { + id: "timeline", + name: "时间线风格", + description: "时间线布局,突出经历的时间顺序", + thumbnail: "timeline", + layout: "timeline", + colorScheme: { + primary: "#18181b", + secondary: "#64748b", + background: "#ffffff", + text: "#212529", + }, + spacing: { + sectionGap: 1, + itemGap: 12, + contentPadding: 24, + }, + basic: { + layout: "right", + }, +}; diff --git a/src/components/templates/timeline/index.tsx b/src/components/templates/timeline/index.tsx new file mode 100644 index 0000000..9716f19 --- /dev/null +++ b/src/components/templates/timeline/index.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { ResumeData } from "@/types/resume"; +import { ResumeTemplate } from "@/types/template"; +import BaseInfo from "./sections/BaseInfo"; +import ExperienceSection from "./sections/ExperienceSection"; +import EducationSection from "./sections/EducationSection"; +import ProjectSection from "./sections/ProjectSection"; +import SkillSection from "./sections/SkillSection"; +import CustomSection from "./sections/CustomSection"; + +interface TimelineTemplateProps { + data: ResumeData; + template: ResumeTemplate; +} + +const TimelineTemplate: React.FC = ({ data, template }) => { + const { colorScheme } = template; + const enabledSections = data.menuSections.filter((s) => s.enabled).sort((a, b) => a.order - b.order); + + const renderTimelineItem = (content: React.ReactNode, title: string) => ( +
+
+
+
+ {title} +
+
{content}
+
+ ); + + const renderSection = (sectionId: string) => { + switch (sectionId) { + case "basic": + return ; + case "experience": + return ; + case "education": + return ; + case "skills": + return ; + case "projects": + return ; + default: + if (sectionId in data.customData) { + const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId; + return ; + } + return null; + } + }; + + return ( +
+ {enabledSections.map((section) => { + if (section.id === "basic") { + return
{renderSection(section.id)}
; + } + const sectionTitle = data.menuSections.find((s) => s.id === section.id)?.title || section.id; + return ( +
+
+ {renderTimelineItem(renderSection(section.id), sectionTitle)} +
+
+ ); + })} +
+ ); +}; + +export default TimelineTemplate; diff --git a/src/components/templates/timeline/sections/BaseInfo.tsx b/src/components/templates/timeline/sections/BaseInfo.tsx new file mode 100644 index 0000000..e62b10c --- /dev/null +++ b/src/components/templates/timeline/sections/BaseInfo.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { motion } from "framer-motion"; +import * as Icons from "lucide-react"; +import { cn, formatDateString } from "@/lib/utils"; +import { BasicInfo, getBorderRadiusValue, GlobalSettings } from "@/types/resume"; +import { ResumeTemplate } from "@/types/template"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { useTranslations, useLocale } from "@/i18n/compat/client"; +import GithubContribution from "@/components/shared/GithubContribution"; + +interface BaseInfoProps { + basic: BasicInfo | undefined; + globalSettings: GlobalSettings | undefined; + template?: ResumeTemplate; +} + +const BaseInfo = ({ basic = {} as BasicInfo, globalSettings, template }: BaseInfoProps) => { + const t = useTranslations("workbench"); + const locale = useLocale(); + const useIconMode = globalSettings?.useIconMode ?? false; + const layout = basic?.layout || "left"; + + const getIcon = (iconName: string | undefined) => { + const IconComponent = Icons[iconName as keyof typeof Icons] as React.ElementType; + return IconComponent ? : null; + }; + + const getOrderedFields = React.useMemo(() => { + if (!basic.fieldOrder) { + return [{ key: "email", value: basic.email, icon: basic.icons?.email || "Mail", label: "电子邮箱", visible: true, custom: false }] + .filter((item) => Boolean(item.value && item.visible)); + } + return basic.fieldOrder + .filter((field) => field.visible !== false && field.key !== "name" && field.key !== "title") + .map((field) => ({ + key: field.key, value: field.key === "birthDate" && basic[field.key] ? formatDateString(basic[field.key] as string, locale) : (basic[field.key] as string), + icon: basic.icons?.[field.key] || "User", label: field.label, visible: field.visible, custom: field.custom, + })) + .filter((item) => Boolean(item.value)); + }, [basic]); + + const allFields = [ + ...getOrderedFields, + ...(basic.customFields?.filter((field) => field.visible !== false).map((field) => ({ + key: field.id, value: field.value, icon: field.icon, label: field.label, visible: true, custom: true, + })) || []), + ]; + + const nameField = basic.fieldOrder?.find((f) => f.key === "name") || { key: "name", label: "姓名", visible: true }; + const titleField = basic.fieldOrder?.find((f) => f.key === "title") || { key: "title", label: "职位", visible: true }; + + const PhotoComponent = basic.photo && basic.photoConfig?.visible && ( + +
+ {`${basic.name}'s +
+
+ ); + + const layoutStyles = { + left: { container: "flex items-center justify-between gap-6", leftContent: "flex items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-left" }, + right: { container: "flex items-center justify-between gap-6 flex-row-reverse", leftContent: "flex justify-end items-center gap-6", fields: "grid grid-cols-2 gap-x-8 gap-y-2 justify-start", nameTitle: "text-right" }, + center: { container: "flex flex-col items-center gap-3", leftContent: "flex flex-col items-center gap-4", fields: "w-full flex justify-start items-center flex-wrap gap-3", nameTitle: "text-center" }, + }; + + const styles = layoutStyles[layout as keyof typeof layoutStyles] || layoutStyles.left; + + return ( + +
+
+ {PhotoComponent} +
+ {nameField.visible !== false && basic[nameField.key] && ( + {basic[nameField.key] as string} + )} + {titleField.visible !== false && basic[titleField.key] && ( + {basic[titleField.key] as string} + )} +
+
+ + {allFields.map((item) => ( + + {useIconMode ? ( +
+ {getIcon(item.icon)} + {item.key === "email" ? {item.value} : {item.value}} +
+ ) : ( +
+ {!item.custom && {t(`basicPanel.basicFields.${item.key}`)}:} + {item.custom && {item.label}:} + {item.value} +
+ )} +
+ ))} +
+
+ {basic.githubContributionsVisible && ( + + )} +
+ ); +}; + +export default BaseInfo; diff --git a/src/components/templates/timeline/sections/CustomSection.tsx b/src/components/templates/timeline/sections/CustomSection.tsx new file mode 100644 index 0000000..ff2f970 --- /dev/null +++ b/src/components/templates/timeline/sections/CustomSection.tsx @@ -0,0 +1,58 @@ +import { AnimatePresence, motion } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { GlobalSettings, CustomItem } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface CustomSectionProps { + sectionId: string; + title: string; + items: CustomItem[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const CustomSection = ({ sectionId, title, items, globalSettings, showTitle = true }: CustomSectionProps) => { + const locale = useLocale(); + const visibleItems = items?.filter((item) => item.visible && (item.title || item.description)); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleItems.map((item) => ( + + +
+

{item.title}

+
+ {centerSubtitle && ( + + {item.subtitle} + + )} + + {formatDateString(item.dateRange, locale)} + +
+ {!centerSubtitle && item.subtitle && ( + {item.subtitle} + )} + {item.description && ( + + )} + + ))} +
+
+ ); +}; + +export default CustomSection; diff --git a/src/components/templates/timeline/sections/EducationSection.tsx b/src/components/templates/timeline/sections/EducationSection.tsx new file mode 100644 index 0000000..d49d094 --- /dev/null +++ b/src/components/templates/timeline/sections/EducationSection.tsx @@ -0,0 +1,61 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { Education, GlobalSettings } from "@/types/resume"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { useLocale } from "@/i18n/compat/client"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; + +interface EducationSectionProps { + education?: Education[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const EducationSection = ({ education, globalSettings, showTitle = true }: EducationSectionProps) => { + const locale = useLocale(); + const visibleEducation = education?.filter((edu) => edu.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleEducation?.map((edu) => ( + + +
+ {edu.school} +
+ {centerSubtitle && ( + + {[edu.major, edu.degree].filter(Boolean).join(" · ")} + {edu.gpa && ` · GPA ${edu.gpa}`} + + )} + + {`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`} + +
+ {!centerSubtitle && ( + + {[edu.major, edu.degree].filter(Boolean).join(" · ")} + {edu.gpa && ` · GPA ${edu.gpa}`} + + )} + {edu.description && ( + + )} + + ))} +
+
+ ); +}; + +export default EducationSection; diff --git a/src/components/templates/timeline/sections/ExperienceSection.tsx b/src/components/templates/timeline/sections/ExperienceSection.tsx new file mode 100644 index 0000000..bb4d3ba --- /dev/null +++ b/src/components/templates/timeline/sections/ExperienceSection.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Experience, GlobalSettings } from "@/types/resume"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface ExperienceSectionProps { + experiences?: Experience[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const ExperienceSection: React.FC = ({ experiences, globalSettings, showTitle = true }) => { + const locale = useLocale(); + const visibleExperiences = experiences?.filter((exp) => exp.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + {visibleExperiences?.map((exp) => ( + + +
+ {exp.company} +
+ {centerSubtitle && ( + + {exp.position} + + )} +
+ {formatDateString(exp.date, locale)} +
+
+ {exp.position && !centerSubtitle && ( + {exp.position} + )} + {exp.details && ( + + )} + + ))} +
+
+ ); +}; + +export default ExperienceSection; diff --git a/src/components/templates/timeline/sections/ProjectSection.tsx b/src/components/templates/timeline/sections/ProjectSection.tsx new file mode 100644 index 0000000..3607f72 --- /dev/null +++ b/src/components/templates/timeline/sections/ProjectSection.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { Project, GlobalSettings } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; +import { formatDateString } from "@/lib/utils"; +import { useLocale } from "@/i18n/compat/client"; + +interface ProjectSectionProps { + projects: Project[]; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const ProjectSection: React.FC = ({ projects, globalSettings, showTitle = true }) => { + const locale = useLocale(); + const visibleProjects = projects?.filter((p) => p.visible); + const centerSubtitle = globalSettings?.centerSubtitle; + const flexLayout = globalSettings?.flexibleHeaderLayout; + + return ( + + + + + {visibleProjects.map((project) => ( + + +
+

{project.name}

+
+ {project.link && !centerSubtitle && ( + + {(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()} + + )} + {!project.link && !centerSubtitle && !flexLayout &&
} + {centerSubtitle && ( + + {project.role} + + )} +
+ {formatDateString(project.date, locale)} +
+ + {project.role && !centerSubtitle && ( + {project.role} + )} + {project.link && centerSubtitle && ( + {project.link} + )} + {project.description && ( + + )} + + ))} + + + + ); +}; + +export default ProjectSection; diff --git a/src/components/templates/timeline/sections/SectionTitle.tsx b/src/components/templates/timeline/sections/SectionTitle.tsx new file mode 100644 index 0000000..4827b1f --- /dev/null +++ b/src/components/templates/timeline/sections/SectionTitle.tsx @@ -0,0 +1,41 @@ +import { useMemo } from "react"; +import { GlobalSettings } from "@/types/resume"; +import { useTemplateContext } from "../../TemplateContext"; +import { useResumeStore } from "@/store/useResumeStore"; + +interface SectionTitleProps { + globalSettings?: GlobalSettings; + type: string; + title?: string; + showTitle?: boolean; +} + +const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => { + const { activeResume } = useResumeStore(); + const templateContext = useTemplateContext(); + const menuSections = templateContext?.menuSections ?? activeResume?.menuSections ?? []; + + const renderTitle = useMemo(() => { + if (type === "custom") return title; + return menuSections.find((s) => s.id === type)?.title; + }, [menuSections, type, title]); + + const themeColor = globalSettings?.themeColor; + if (!showTitle) return null; + + // Timeline SectionTitle is rendered by the template wrapper (renderTimelineItem) + // This is a minimal fallback for basic section + return ( +
+ {renderTitle} +
+ ); +}; + +export default SectionTitle; diff --git a/src/components/templates/timeline/sections/SkillSection.tsx b/src/components/templates/timeline/sections/SkillSection.tsx new file mode 100644 index 0000000..6721caf --- /dev/null +++ b/src/components/templates/timeline/sections/SkillSection.tsx @@ -0,0 +1,27 @@ +import { motion } from "framer-motion"; +import SectionTitle from "./SectionTitle"; +import SectionWrapper from "../../shared/SectionWrapper"; +import { GlobalSettings } from "@/types/resume"; +import { normalizeRichTextContent } from "@/lib/richText"; + +interface SkillSectionProps { + skill?: string; + globalSettings?: GlobalSettings; + showTitle?: boolean; +} + +const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => { + return ( + + + + + + + ); +}; + +export default SkillSection; diff --git a/src/config/index.ts b/src/config/index.ts index 0cceeab..5f6d8b3 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,5 +1,7 @@ import { BasicFieldType } from "@/types/resume"; -import { ResumeTemplate } from "@/types/template"; + +export { DEFAULT_TEMPLATES } from "@/components/templates/registry"; + export const DEFAULT_FIELD_ORDER: BasicFieldType[] = [ { id: "1", key: "name", label: "姓名", type: "text", visible: true }, @@ -17,157 +19,6 @@ export const DEFAULT_FIELD_ORDER: BasicFieldType[] = [ { id: "7", key: "location", label: "所在地", type: "text", visible: true } ]; -export const DEFAULT_TEMPLATES: ResumeTemplate[] = [ - { - id: "classic", - name: "经典模板", - description: "传统简约的简历布局,适合大多数求职场景", - thumbnail: "classic", - layout: "classic", - colorScheme: { - primary: "#000000", - secondary: "#4b5563", - background: "#ffffff", - text: "#212529" - }, - spacing: { - sectionGap: 24, - itemGap: 16, - contentPadding: 32 - }, - basic: { - layout: "center" - } - }, - { - id: "modern", - name: "两栏布局", - description: "经典两栏,突出个人特色", - thumbnail: "modern", - layout: "modern", - colorScheme: { - primary: "#000000", - secondary: "#6b7280", - background: "#ffffff", - text: "#212529" - }, - spacing: { - sectionGap: 20, - itemGap: 20, - contentPadding: 1 - }, - basic: { - layout: "center" - } - }, - { - id: "left-right", - name: "模块标题背景色", - description: "模块标题背景鲜明,突出美观特色", - thumbnail: "leftRight", - layout: "left-right", - colorScheme: { - primary: "#000000", - secondary: "#9ca3af", - background: "#ffffff", - text: "#212529" - }, - spacing: { - sectionGap: 24, - itemGap: 16, - contentPadding: 32 - }, - basic: { - layout: "left" - } - }, - { - id: "timeline", - name: "时间线风格", - description: "时间线布局,突出经历的时间顺序", - thumbnail: "timeline", - layout: "timeline", - colorScheme: { - primary: "#18181b", - secondary: "#64748b", - background: "#ffffff", - text: "#212529" - }, - spacing: { - sectionGap: 1, - itemGap: 12, - contentPadding: 24 - }, - basic: { - layout: "right" - } - }, - { - id: "minimalist", - name: "极简模板", - description: "大面积留白,干净纯粹的排版风格", - thumbnail: "minimalist", - layout: "minimalist", - colorScheme: { - primary: "#171717", - secondary: "#737373", - background: "#ffffff", - text: "#171717" - }, - spacing: { - sectionGap: 32, - itemGap: 24, - contentPadding: 40 - }, - basic: { - layout: "center" - } - }, - - { - id: "elegant", - name: "优雅模板", - description: "居中标题单列设计,具有高级感的分隔线", - thumbnail: "elegant", - layout: "elegant", - colorScheme: { - primary: "#18181b", - secondary: "#71717a", - background: "#ffffff", - text: "#27272a" - }, - spacing: { - sectionGap: 28, - itemGap: 18, - contentPadding: 32 - }, - basic: { - layout: "center" - } - }, - { - id: "creative", - name: "创意模板", - description: "视觉错落设计,灵动活泼展现个性", - thumbnail: "creative", - layout: "creative", - colorScheme: { - primary: "#18181b", - secondary: "#64748b", - background: "#ffffff", - text: "#1e293b" - }, - spacing: { - sectionGap: 24, - itemGap: 16, - contentPadding: 28 - }, - basic: { - layout: "left" - } - } -]; - export const GITHUB_REPO_URL = "https://github.com/JOYCEQL/magic-resume"; export const PDF_EXPORT_CONFIG = { diff --git a/src/config/templates.ts b/src/config/templates.ts deleted file mode 100644 index 12623eb..0000000 --- a/src/config/templates.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { TemplateConfig } from "@/types/template"; - -export const templateConfigs: Record = { - default: { - sectionTitle: { - styles: { - fontSize: 18, - }, - }, - }, - classic: { - sectionTitle: { - className: "border-b pb-2", - styles: { - fontSize: 18, - borderColor: "var(--theme-color)", - }, - }, - }, - modern: { - sectionTitle: { - className: "font-semibold mb-2 uppercase tracking-wider", - styles: { - fontSize: 18, - }, - }, - }, - "left-right": { - sectionTitle: { - className: "pl-1 flex items-center", - styles: { - fontSize: 18, - backgroundColor: "var(--theme-color)", - opacity: 0.1, - color: "var(--theme-color)", - borderLeftWidth: "3px", - borderLeftStyle: "solid", - borderLeftColor: "var(--theme-color)", - }, - }, - }, - minimalist: { - sectionTitle: { - className: "mb-3 tracking-widest", - styles: { - fontSize: 16, - }, - }, - }, - - elegant: { - sectionTitle: { - className: "flex items-center justify-center w-full mb-4 relative", - styles: { - fontSize: 20, - }, - }, - }, - creative: { - sectionTitle: { - className: "inline-block px-3 py-1 mb-3 rounded-md text-white shadow-sm", - styles: { - fontSize: 16, - backgroundColor: "var(--theme-color)", - }, - }, - }, - -}; - diff --git a/src/types/template.ts b/src/types/template.ts index a02a6c3..a735bec 100644 --- a/src/types/template.ts +++ b/src/types/template.ts @@ -5,7 +5,7 @@ export interface ResumeTemplate { name: string; description: string; thumbnail: string; - layout: "classic" | "modern" | "left-right" | "timeline" | "minimalist" | "elegant" | "creative"; + layout: string; colorScheme: { primary: string; secondary: string;