refactor: reorganize template system

This commit is contained in:
JOYCEQL
2026-02-28 00:22:35 +08:00
parent bc49353958
commit 8a4c6d5023
83 changed files with 3594 additions and 2142 deletions
-278
View File
@@ -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 ? <IconComponent className="w-4 h-4" /> : 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 && (
<motion.div layout="position">
<div
style={{
width: `${basic.photoConfig?.width || 100}px`,
height: `${basic.photoConfig?.height || 100}px`,
borderRadius: getBorderRadiusValue(
basic.photoConfig || {
borderRadius: "none",
customBorderRadius: 0,
}
),
overflow: "hidden",
}}
>
<img
src={basic.photo}
alt={`${basic.name}'s photo`}
className="w-full h-full object-cover"
/>
</div>
</motion.div>
);
// 基础样式
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 = (
<div className={nameTitleClass}>
{nameField && basic[nameField.key] && (
<motion.h1
layout="position"
className="font-bold"
style={{
fontSize: `30px`,
}}
>
{basic[nameField.key] as string}
</motion.h1>
)}
{titleField && basic[titleField.key] && (
<motion.h2
layout="position"
style={{
fontSize: "18px",
}}
>
{basic[titleField.key] as string}
</motion.h2>
)}
</div>
);
const FieldsComponent = (
<motion.div
layout="position"
className={fieldsContainerClass}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
color: isWhiteTextTemplate ? "#fff" : "rgb(75, 85, 99)",
maxWidth: layout === "center" ? "none" : "600px",
}}
>
{allFields.map((item) => (
<motion.div
key={item.key}
className={cn(baseFieldItemClass, isWhiteTextTemplate && "text-[#fff]")}
style={{
width: isModernTemplate ? "100%" : "",
}}
>
{useIconMode ? (
<div className="flex items-center gap-1">
{getIcon(item.icon)}
{item.key === "email" ? (
<a href={`mailto:${item.value}`} className="underline">
{item.value}
</a>
) : (
<span>{item.value}</span>
)}
</div>
) : (
<div className="flex items-center gap-2 overflow-hidden">
{!item.custom && (
<span>{t(`basicPanel.basicFields.${item.key}`)}:</span>
)}
{item.custom && <span>{item.label}:</span>}
<span className="truncate" suppressHydrationWarning>
{item.value}
</span>
</div>
)}
</motion.div>
))}
</motion.div>
);
return (
<div className={containerClass} onClick={() => setActiveSection("basic")}>
<div className={leftContentClass}>
{PhotoComponent}
{NameTitleComponent}
</div>
{FieldsComponent}
</div>
);
};
export default BaseInfo;
-127
View File
@@ -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 (
<motion.div
className="hover:cursor-pointer hover:bg-gray-100 rounded-md transition-all duration-300 ease-in-out hover:shadow-md"
style={{
marginTop: `${globalSettings?.sectionSpacing || 24}px`,
}}
onClick={() => {
setActiveSection(sectionId);
}}
>
<SectionTitle
title={title}
type="custom"
globalSettings={globalSettings}
showTitle={showTitle}
/>
<AnimatePresence mode="popLayout">
{visibleItems.map((item) => (
<motion.div
key={item.id}
layout="position"
style={{
marginTop: `${globalSettings?.paragraphSpacing}px`,
}}
>
<motion.div
layout="position"
className="flex items-center gap-2"
>
<div className={`flex items-center gap-2 ${globalSettings?.flexibleHeaderLayout ? '' : 'flex-[1.5]'}`}>
<h4
className="font-bold"
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
{item.title}
</h4>
</div>
{centerSubtitle && (
<motion.div
layout="position"
className={`text-subtitleFont ${globalSettings?.flexibleHeaderLayout ? 'ml-[16px]' : 'flex-1'}`}
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
{item.subtitle}
</motion.div>
)}
<span
className={`text-subtitleFont shrink-0 ${globalSettings?.flexibleHeaderLayout ? 'ml-auto' : 'flex-1 text-right'}`}
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
{formatDateString(item.dateRange, locale)}
</span>
</motion.div>
{!centerSubtitle && item.subtitle && (
<motion.div
layout="position"
className="text-subtitleFont mt-1"
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
{item.subtitle}
</motion.div>
)}
{item.description && (
<motion.div
layout="position"
className="mt-2 text-baseFont"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6,
}}
dangerouslySetInnerHTML={{
__html: normalizeRichTextContent(item.description),
}}
/>
)}
</motion.div>
))}
</AnimatePresence>
</motion.div>
);
};
export default CustomSection;
-127
View File
@@ -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 (
<motion.div
className="
hover:cursor-pointer
hover:bg-gray-100
rounded-md
transition-all
duration-300
ease-in-out
hover:shadow-md"
style={{
marginTop: `${globalSettings?.sectionSpacing || 24}px`,
}}
onClick={() => {
setActiveSection("education");
}}
>
<SectionTitle
type="education"
globalSettings={globalSettings}
showTitle={showTitle}
></SectionTitle>
<AnimatePresence mode="popLayout">
{visibleEducation?.map((edu) => (
<motion.div
layout="position"
key={edu.id}
style={{
marginTop: `${globalSettings?.paragraphSpacing}px`,
}}
>
<motion.div
layout="position"
className="flex items-center gap-2"
>
<div
className={`font-bold ${globalSettings?.flexibleHeaderLayout ? '' : 'flex-1'}`}
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
<span>{edu.school}</span>
</div>
{globalSettings?.centerSubtitle && (
<motion.div
layout="position"
className={`text-subtitleFont ${globalSettings?.flexibleHeaderLayout ? 'ml-[16px]' : 'flex-1'}`}
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
<span
className={`text-subtitleFont shrink-0 ${globalSettings?.flexibleHeaderLayout ? 'ml-auto' : 'flex-1 text-right'}`}
suppressHydrationWarning
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
{`${formatDateString(edu.startDate, locale)} - ${formatDateString(
edu.endDate,
locale
)}`}
</span>
</motion.div>
{!globalSettings?.centerSubtitle && (
<motion.div
layout="position"
className="text-subtitleFont mt-1"
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
{edu.description && (
<motion.div
layout="position"
className="mt-2 text-baseFont"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6,
}}
dangerouslySetInnerHTML={{
__html: normalizeRichTextContent(edu.description),
}}
/>
)}
</motion.div>
))}
</AnimatePresence>
</motion.div>
);
};
export default EducationSection;
@@ -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<HTMLDivElement, ExperienceItemProps>(
({ experience, globalSettings }, ref) => {
const centerSubtitle = globalSettings?.centerSubtitle;
const flexLayout = globalSettings?.flexibleHeaderLayout;
const gridColumns = centerSubtitle ? 3 : 2;
const locale = useLocale();
return (
<motion.div
style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}
layout="position"
>
<motion.div
className="flex items-center gap-2"
>
<div
className={`font-bold ${flexLayout ? '' : 'flex-[1.5]'}`}
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
{experience.company}
</div>
{centerSubtitle && (
<motion.div
className={`text-subtitleFont ${flexLayout ? 'ml-[16px]' : 'flex-1'}`}
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
{experience.position}
</motion.div>
)}
<div
className={`text-subtitleFont shrink-0 ${flexLayout ? 'ml-auto' : 'flex-1 text-right'}`}
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
{formatDateString(experience.date, locale)}
</div>
</motion.div>
{experience.position && !centerSubtitle && (
<motion.div
className="text-subtitleFont"
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
{experience.position}
</motion.div>
)}
{experience.details && (
<motion.div
className="mt-2 text-baseFont"
dangerouslySetInnerHTML={{
__html: normalizeRichTextContent(experience.details),
}}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6,
}}
></motion.div>
)}
</motion.div>
);
}
);
ExperienceItem.displayName = "ExperienceItem";
const ExperienceSection: React.FC<ExperienceSectionProps> = ({
experiences,
globalSettings,
showTitle = true,
}) => {
const { setActiveSection } = useResumeStore();
const visibleExperiences = experiences?.filter(
(experience) => experience.visible
);
return (
<motion.div
className="hover:cursor-pointer hover:bg-gray-100 rounded-md transition-all duration-300 ease-in-out hover:shadow-md"
style={{
marginTop: `${globalSettings?.sectionSpacing || 24}px`,
}}
onClick={() => {
setActiveSection("experience");
}}
>
{showTitle && (
<SectionTitle type="experience" globalSettings={globalSettings} />
)}
<div>
<AnimatePresence mode="popLayout">
{visibleExperiences?.map((experience) => (
<ExperienceItem
key={experience.id}
experience={experience}
globalSettings={globalSettings}
/>
))}
</AnimatePresence>
</div>
</motion.div>
);
};
export default ExperienceSection;
-185
View File
@@ -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<HTMLDivElement, ProjectItemProps>(
({ project, globalSettings }, ref) => {
const centerSubtitle = globalSettings?.centerSubtitle;
const locale = useLocale();
const flexLayout = globalSettings?.flexibleHeaderLayout;
return (
<motion.div
style={{
marginTop: `${globalSettings?.paragraphSpacing}px`,
}}
>
<motion.div
className="flex items-center gap-2"
>
<div className={`flex items-center gap-2 ${flexLayout ? '' : 'flex-[1.5]'}`}>
<h3
className="font-bold"
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
{project.name}
</h3>
</div>
{project.link && !centerSubtitle && (
<a
href={
project.link.startsWith("http")
? project.link
: `https://${project.link}`
}
target="_blank"
rel="noopener noreferrer"
className={`underline ${flexLayout ? '' : 'flex-1'}`}
title={project.link}
>
{(() => {
try {
const url = new URL(
project.link.startsWith("http")
? project.link
: `https://${project.link}`
);
return url.hostname.replace(/^www\./, "");
} catch (e) {
return project.link;
}
})()}
</a>
)}
{!project.link && !centerSubtitle && !flexLayout && <div className="flex-1"></div>}
{centerSubtitle && (
<motion.div
layout="position"
className={` text-subtitleFont ${flexLayout ? 'ml-[16px]' : 'flex-1'}`}
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
{project.role}
</motion.div>
)}
<div
className={`text-subtitleFont shrink-0 ${flexLayout ? 'ml-auto' : 'flex-1 text-right'}`}
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
{formatDateString(project.date, locale)}
</div>
</motion.div>
{project.role && !centerSubtitle && (
<motion.div
layout="position"
className=" text-subtitleFont"
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`,
}}
>
{project.role}
</motion.div>
)}
{project.link && centerSubtitle && (
<a
href={
project.link.startsWith("http")
? project.link
: `https://${project.link}`
}
target="_blank"
rel="noopener noreferrer"
className="underline"
title={project.link}
>
{project.link}
</a>
)}
{project.description ? (
<motion.div
layout="position"
className="mt-2 text-baseFont"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6,
}}
dangerouslySetInnerHTML={{
__html: normalizeRichTextContent(project.description),
}}
></motion.div>
) : (
<div></div>
)}
</motion.div>
);
}
);
ProjectItem.displayName = "ProjectItem";
interface ProjectSectionProps {
projects: Project[];
globalSettings?: GlobalSettings;
showTitle?: boolean;
}
const ProjectSection: React.FC<ProjectSectionProps> = ({
projects,
globalSettings,
showTitle = true,
}) => {
const { setActiveSection } = useResumeStore();
const visibleProjects = projects?.filter((project) => project.visible);
return (
<motion.div
className="hover:cursor-pointer hover:bg-gray-100 rounded-md transition-all duration-300 ease-in-out hover:shadow-md"
style={{
marginTop: `${globalSettings?.sectionSpacing || 24}px`,
}}
onClick={() => {
setActiveSection("projects");
}}
>
<SectionTitle
type="projects"
globalSettings={globalSettings}
showTitle={showTitle}
/>
<motion.div layout="position">
<AnimatePresence mode="popLayout">
{visibleProjects.map((project) => (
<ProjectItem
key={project.id}
project={project}
globalSettings={globalSettings}
/>
))}
</AnimatePresence>
</motion.div>
</motion.div>
);
};
export default ProjectSection;
-178
View File
@@ -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 (
<h3
className={cn("border-b pb-2")}
style={{
...baseStyles,
borderColor: themeColor,
}}
>
{renderTitle}
</h3>
);
case "left-right":
return (
<div className="relative">
<div
className="absolute inset-0"
style={{
backgroundColor: themeColor,
opacity: 0.1,
}}
/>
<h3
className={cn("pl-4 py-1 flex items-center relative")}
style={{
...baseStyles,
color: themeColor,
borderLeft: `3px solid ${themeColor}`,
}}
>
{renderTitle}
</h3>
</div>
);
case "classic":
return (
<h3
className={cn("pb-2 border-b")}
style={{
...baseStyles,
color: themeColor,
borderColor: themeColor,
}}
>
{renderTitle}
</h3>
);
case "minimalist":
return (
<h3
className={cn("pb-1 mb-2 tracking-widest uppercase")}
style={{
...baseStyles,
color: themeColor,
}}
>
{renderTitle}
</h3>
);
case "elegant":
return (
<div className="flex items-center justify-center w-full mb-4 relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div
className="w-full border-t"
style={{ borderColor: themeColor, opacity: 0.3 }}
></div>
</div>
<h3
className={cn("relative bg-white px-4 text-center")}
style={{
...baseStyles,
color: themeColor,
marginBottom: 0,
}}
>
{renderTitle}
</h3>
</div>
);
case "creative":
return (
<h3
className={cn("inline-block px-3 py-1 rounded text-white shadow-sm mb-3")}
style={{
...baseStyles,
backgroundColor: themeColor,
color: "#ffffff",
}}
>
{renderTitle}
</h3>
);
default:
return (
<h3
className={cn("pb-2")}
style={{
...baseStyles,
color: themeColor,
}}
>
{renderTitle}
</h3>
);
}
};
return renderTemplateTitle();
};
export default SectionTitle;
-48
View File
@@ -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 (
<motion.div
className="hover:cursor-pointer hover:bg-gray-100 rounded-md transition-all duration-300 ease-in-out hover:shadow-md"
style={{
marginTop: `${globalSettings?.sectionSpacing || 24}px`,
}}
onClick={() => {
setActiveSection("skills");
}}
>
{showTitle && <SectionTitle type="skills" globalSettings={globalSettings} />}
<motion.div
style={{
marginTop: `${globalSettings?.paragraphSpacing}px`,
}}
>
<motion.div
className="text-baseFont"
layout="position"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6,
}}
dangerouslySetInnerHTML={{
__html: normalizeRichTextContent(skill),
}}
/>
</motion.div>
</motion.div>
);
};
export default SkillSection;
@@ -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<ClassicTemplateProps> = ({
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 (
<>
<BaseInfo basic={data.basic} globalSettings={data.globalSettings} />
{data.basic.githubContributionsVisible && (
<GithubContribution
className="mt-2"
githubKey={data.basic.githubKey}
username={data.basic.githubUseName}
/>
)}
</>
);
case "experience":
return (
<ExperienceSection
experiences={data.experience}
globalSettings={data.globalSettings}
/>
);
case "education":
return (
<EducationSection
education={data.education}
globalSettings={data.globalSettings}
/>
);
case "skills":
return (
<SkillSection
skill={data.skillContent}
globalSettings={data.globalSettings}
/>
);
case "projects":
return (
<ProjectSection
projects={data.projects}
globalSettings={data.globalSettings}
/>
);
default:
if (sectionId in data.customData) {
const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId;
return (
<CustomSection
title={sectionTitle}
key={sectionId}
sectionId={sectionId}
items={data.customData[sectionId]}
globalSettings={data.globalSettings}
/>
);
}
return null;
}
};
return (
<div
className="flex flex-col w-full min-h-screen"
style={{
backgroundColor: colorScheme.background,
color: colorScheme.text,
}}
>
{sortedSections.map((section) => (
<div key={section.id}>{renderSection(section.id)}</div>
))}
</div>
);
};
export default ClassicTemplate;
@@ -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<CreativeTemplateProps> = ({
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 (
<ExperienceSection
experiences={data.experience}
globalSettings={data.globalSettings}
/>
);
case "education":
return (
<EducationSection
education={data.education}
globalSettings={data.globalSettings}
/>
);
case "skills":
return (
<SkillSection
skill={data.skillContent}
globalSettings={data.globalSettings}
/>
);
case "projects":
return (
<ProjectSection
projects={data.projects}
globalSettings={data.globalSettings}
/>
);
default:
if (sectionId in data.customData) {
const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId;
return (
<CustomSection
title={sectionTitle}
key={sectionId}
sectionId={sectionId}
items={data.customData[sectionId]}
globalSettings={data.globalSettings}
/>
);
}
return null;
}
};
const basicSection = sortedSections.find((section) => section.id === "basic");
const otherSections = sortedSections.filter(
(section) => section.id !== "basic"
);
return (
<div
className="flex flex-col w-full min-h-screen"
style={{
backgroundColor: colorScheme.background,
color: colorScheme.text,
}}
>
{/* 顶部色块(如果存在basic区块) */}
{basicSection && (
<div
className="w-full relative py-8 px-8 mb-6 rounded-b-3xl"
style={{
backgroundColor: data.globalSettings.themeColor,
color: "#ffffff"
}}
>
<div className="relative z-10 w-full">
<BaseInfo basic={data.basic} globalSettings={data.globalSettings} template={template} />
{data.basic.githubContributionsVisible && (
<GithubContribution
className="mt-2 text-white"
githubKey={data.basic.githubKey}
username={data.basic.githubUseName}
/>
)}
</div>
</div>
)}
{/* 下方内容区 */}
<div className="px-8 w-full w-max-4xl mx-auto">
{otherSections.map((section) => (
<div key={section.id}>{renderSection(section.id)}</div>
))}
</div>
</div>
);
};
export default CreativeTemplate;
@@ -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<ElegantTemplateProps> = ({
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 (
<div className="text-center w-full flex flex-col items-center">
<BaseInfo basic={data.basic} globalSettings={data.globalSettings} template={template} />
{data.basic.githubContributionsVisible && (
<GithubContribution
className="mt-2 justify-center"
githubKey={data.basic.githubKey}
username={data.basic.githubUseName}
/>
)}
</div>
);
case "experience":
return (
<ExperienceSection
experiences={data.experience}
globalSettings={data.globalSettings}
/>
);
case "education":
return (
<EducationSection
education={data.education}
globalSettings={data.globalSettings}
/>
);
case "skills":
return (
<SkillSection
skill={data.skillContent}
globalSettings={data.globalSettings}
/>
);
case "projects":
return (
<ProjectSection
projects={data.projects}
globalSettings={data.globalSettings}
/>
);
default:
if (sectionId in data.customData) {
const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId;
return (
<CustomSection
title={sectionTitle}
key={sectionId}
sectionId={sectionId}
items={data.customData[sectionId]}
globalSettings={data.globalSettings}
/>
);
}
return null;
}
};
return (
<div
className="flex flex-col w-full min-h-screen items-center"
style={{
backgroundColor: colorScheme.background,
color: colorScheme.text,
}}
>
{/* 优雅版采用单行且较为居中的排版风格 */}
<div className="w-full max-w-4xl px-8">
{sortedSections.map((section) => (
<div key={section.id} className="w-full">
{renderSection(section.id)}
</div>
))}
</div>
</div>
);
};
export default ElegantTemplate;
@@ -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<LeftRightTemplateProps> = ({
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 (
<>
<BaseInfo
basic={data.basic}
globalSettings={data.globalSettings}
template={template}
></BaseInfo>
{data.basic.githubContributionsVisible && (
<GithubContribution
className="mt-2"
githubKey={data.basic.githubKey}
username={data.basic.githubUseName}
/>
)}
</>
);
case "experience":
return (
<ExperienceSection
experiences={data.experience}
globalSettings={data.globalSettings}
/>
);
case "education":
return (
<EducationSection
education={data.education}
globalSettings={data.globalSettings}
/>
);
case "skills":
return (
<SkillSection
skill={data.skillContent}
globalSettings={data.globalSettings}
/>
);
case "projects":
return (
<ProjectSection
projects={data.projects}
globalSettings={data.globalSettings}
/>
);
default:
if (sectionId in data.customData) {
const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId;
return (
<CustomSection
title={sectionTitle}
key={sectionId}
sectionId={sectionId}
items={data.customData[sectionId]}
globalSettings={data.globalSettings}
/>
);
}
return null;
}
};
return (
<div
className="flex flex-col w-full"
style={{
backgroundColor: colorScheme.background,
color: colorScheme.text,
}}
>
{sortedSections.map((section) => (
<div key={section.id}>{renderSection(section.id)}</div>
))}
</div>
);
};
export default LeftRightTemplate;
@@ -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<MinimalistTemplateProps> = ({
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 (
<>
<BaseInfo basic={data.basic} globalSettings={data.globalSettings} template={template} />
{data.basic.githubContributionsVisible && (
<GithubContribution
className="mt-2"
githubKey={data.basic.githubKey}
username={data.basic.githubUseName}
/>
)}
</>
);
case "experience":
return (
<ExperienceSection
experiences={data.experience}
globalSettings={data.globalSettings}
/>
);
case "education":
return (
<EducationSection
education={data.education}
globalSettings={data.globalSettings}
/>
);
case "skills":
return (
<SkillSection
skill={data.skillContent}
globalSettings={data.globalSettings}
/>
);
case "projects":
return (
<ProjectSection
projects={data.projects}
globalSettings={data.globalSettings}
/>
);
default:
if (sectionId in data.customData) {
const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId;
return (
<CustomSection
title={sectionTitle}
key={sectionId}
sectionId={sectionId}
items={data.customData[sectionId]}
globalSettings={data.globalSettings}
/>
);
}
return null;
}
};
return (
<div
className="flex flex-col w-full min-h-screen"
style={{
backgroundColor: colorScheme.background,
color: colorScheme.text,
}}
>
{/* 极简风格:单列居中体验,更大留白 */}
{sortedSections.map((section) => (
<div key={section.id} className="w-full">
{renderSection(section.id)}
</div>
))}
</div>
);
};
export default MinimalistTemplate;
-111
View File
@@ -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<ModernTemplateProps> = ({ 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 (
<BaseInfo
basic={data.basic}
globalSettings={data.globalSettings}
template={template}
/>
);
case "experience":
return (
<ExperienceSection
experiences={data.experience}
globalSettings={data.globalSettings}
/>
);
case "education":
return (
<EducationSection
education={data.education}
globalSettings={data.globalSettings}
/>
);
case "skills":
return (
<SkillSection
skill={data.skillContent}
globalSettings={data.globalSettings}
/>
);
case "projects":
return (
<ProjectSection
projects={data.projects}
globalSettings={data.globalSettings}
/>
);
default:
if (sectionId in data.customData) {
const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId;
return (
<CustomSection
title={sectionTitle}
key={sectionId}
sectionId={sectionId}
items={data.customData[sectionId]}
globalSettings={data.globalSettings}
/>
);
}
return null;
}
};
const basicSection = sortedSections.find((section) => section.id === "basic");
const otherSections = sortedSections.filter(
(section) => section.id !== "basic"
);
return (
<div className="grid grid-cols-3 w-full">
<div
className="col-span-1 p-4"
style={{
backgroundColor: data.globalSettings.themeColor,
color: "#ffffff",
paddingTop: data.globalSettings.sectionSpacing,
}}
>
{basicSection && renderSection(basicSection.id)}
</div>
<div
className="col-span-2 p-4 pt-0"
style={{
backgroundColor: colorScheme.background,
color: colorScheme.text,
}}
>
{otherSections.map((section) => (
<div key={section.id}>{renderSection(section.id)}</div>
))}
</div>
</div>
);
};
export default ModernTemplate;
@@ -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<TimelineTemplateProps> = ({
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) => (
<div className="relative pl-6">
<div
className="absolute left-0 top-2 h-full w-0.5"
style={{ backgroundColor: "#e5e7eb" }}
/>
<div
className="absolute left-[-6px] top-2 w-3 h-3 rounded-full"
style={{ backgroundColor: colorScheme.primary }}
/>
<div
className="text-xl font-bold mb-4"
style={{
color: data.globalSettings.themeColor,
fontSize: `${data.globalSettings.headerSize || 20}px`,
}}
>
{title}
</div>
<div>{content}</div>
</div>
);
const renderSection = (sectionId: string) => {
const sectionTitle =
data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
switch (sectionId) {
case "basic":
return (
<>
<BaseInfo
basic={data.basic}
globalSettings={data.globalSettings}
showTitle={false}
/>
{data.basic.githubContributionsVisible && (
<GithubContribution
className="mt-2"
githubKey={data.basic.githubKey}
username={data.basic.githubUseName}
/>
)}
</>
);
case "experience":
return (
<div className="timeline-section">
{renderTimelineItem(
<ExperienceSection
experiences={data.experience}
globalSettings={data.globalSettings}
showTitle={false}
/>,
sectionTitle
)}
</div>
);
case "education":
return (
<div className="timeline-section">
{renderTimelineItem(
<EducationSection
education={data.education}
globalSettings={data.globalSettings}
showTitle={false}
/>,
sectionTitle
)}
</div>
);
case "skills":
return (
<div className="timeline-section">
{renderTimelineItem(
<SkillSection
skill={data.skillContent}
globalSettings={data.globalSettings}
showTitle={false}
/>,
sectionTitle
)}
</div>
);
case "projects":
return (
<div className="timeline-section">
{renderTimelineItem(
<ProjectSection
projects={data.projects}
globalSettings={data.globalSettings}
showTitle={false}
/>,
sectionTitle
)}
</div>
);
default:
if (sectionId in data.customData) {
return (
<div className="timeline-section">
{renderTimelineItem(
<CustomSection
title={sectionTitle}
sectionId={sectionId}
items={data.customData[sectionId]}
globalSettings={data.globalSettings}
showTitle={false}
/>,
sectionTitle
)}
</div>
);
}
return null;
}
};
return (
<div
className="flex flex-col w-full min-h-screen pl-[6px] "
style={{
backgroundColor: colorScheme.background,
color: colorScheme.text,
}}
>
{sortedSections.map((section) => (
<div key={section.id} className="mb-4">
{renderSection(section.id)}
</div>
))}
</div>
);
};
export default TimelineTemplate;
@@ -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",
},
};
@@ -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<ClassicTemplateProps> = ({ 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 <BaseInfo basic={data.basic} globalSettings={data.globalSettings} template={template} />;
case "experience":
return <ExperienceSection experiences={data.experience} globalSettings={data.globalSettings} />;
case "education":
return <EducationSection education={data.education} globalSettings={data.globalSettings} />;
case "skills":
return <SkillSection skill={data.skillContent} globalSettings={data.globalSettings} />;
case "projects":
return <ProjectSection projects={data.projects} globalSettings={data.globalSettings} />;
default:
if (sectionId in data.customData) {
const sectionTitle = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
return <CustomSection title={sectionTitle} sectionId={sectionId} items={data.customData[sectionId]} globalSettings={data.globalSettings} />;
}
return null;
}
};
return (
<div className="flex flex-col w-full min-h-screen" style={{ backgroundColor: colorScheme.background, color: colorScheme.text }}>
{enabledSections.map((section) => (
<div key={section.id}>{renderSection(section.id)}</div>
))}
</div>
);
};
export default ClassicTemplate;
@@ -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 ? <IconComponent className="w-4 h-4" /> : 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 && (
<motion.div layout="position">
<div style={{ width: `${basic.photoConfig?.width || 100}px`, height: `${basic.photoConfig?.height || 100}px`, borderRadius: getBorderRadiusValue(basic.photoConfig || { borderRadius: "none", customBorderRadius: 0 }), overflow: "hidden" }}>
<img src={basic.photo} alt={`${basic.name}'s photo`} className="w-full h-full object-cover" />
</div>
</motion.div>
);
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 (
<SectionWrapper sectionId="basic">
<div className={styles.container}>
<div className={styles.leftContent}>
{PhotoComponent}
<div className={cn("flex flex-col", styles.nameTitle)}>
{nameField.visible !== false && basic[nameField.key] && (
<motion.h1 layout="position" className="font-bold" style={{ fontSize: "30px" }}>{basic[nameField.key] as string}</motion.h1>
)}
{titleField.visible !== false && basic[titleField.key] && (
<motion.h2 layout="position" style={{ fontSize: "18px" }}>{basic[titleField.key] as string}</motion.h2>
)}
</div>
</div>
<motion.div layout="position" className={styles.fields} style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, color: "rgb(75, 85, 99)", maxWidth: layout === "center" ? "none" : "600px" }}>
{allFields.map((item) => (
<motion.div key={item.key} className="flex items-center whitespace-nowrap overflow-hidden text-baseFont">
{useIconMode ? (
<div className="flex items-center gap-1">
{getIcon(item.icon)}
{item.key === "email" ? <a href={`mailto:${item.value}`} className="underline">{item.value}</a> : <span>{item.value}</span>}
</div>
) : (
<div className="flex items-center gap-2 overflow-hidden">
{!item.custom && <span>{t(`basicPanel.basicFields.${item.key}`)}:</span>}
{item.custom && <span>{item.label}:</span>}
<span className="truncate" suppressHydrationWarning>{item.value}</span>
</div>
)}
</motion.div>
))}
</motion.div>
</div>
{basic.githubContributionsVisible && (
<GithubContribution className="mt-2" githubKey={basic.githubKey} username={basic.githubUseName} />
)}
</SectionWrapper>
);
};
export default BaseInfo;
@@ -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 (
<SectionWrapper sectionId={sectionId} style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle title={title} type="custom" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleItems.map((item) => (
<motion.div key={item.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div layout="position" className="flex items-center gap-2">
<div className={`flex items-center gap-2 ${flexLayout ? "" : "flex-[1.5]"}`}>
<h4 className="font-bold" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{item.title}</h4>
</div>
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{item.subtitle}
</motion.div>
)}
<span className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(item.dateRange, locale)}
</span>
</motion.div>
{!centerSubtitle && item.subtitle && (
<motion.div layout="position" className="text-subtitleFont mt-1" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{item.subtitle}</motion.div>
)}
{item.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(item.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default CustomSection;
@@ -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 (
<SectionWrapper sectionId="education" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="education" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleEducation?.map((edu) => (
<motion.div key={edu.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div layout="position" className="flex items-center gap-2">
<div className={`font-bold ${flexLayout ? "" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{edu.school}
</div>
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
<span className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} suppressHydrationWarning
style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`}
</span>
</motion.div>
{!centerSubtitle && (
<motion.div layout="position" className="text-subtitleFont mt-1" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
{edu.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(edu.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default EducationSection;
@@ -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<ExperienceSectionProps> = ({ experiences, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleExperiences = experiences?.filter((exp) => exp.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const flexLayout = globalSettings?.flexibleHeaderLayout;
return (
<SectionWrapper sectionId="experience" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="experience" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleExperiences?.map((exp) => (
<motion.div key={exp.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="flex items-center gap-2">
<div className={`font-bold ${flexLayout ? "" : "flex-[1.5]"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{exp.company}
</div>
{centerSubtitle && (
<motion.div className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{exp.position}
</motion.div>
)}
<div className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(exp.date, locale)}
</div>
</motion.div>
{exp.position && !centerSubtitle && (
<motion.div className="text-subtitleFont" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{exp.position}</motion.div>
)}
{exp.details && (
<motion.div className="mt-2 text-baseFont" dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(exp.details) }}
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default ExperienceSection;
@@ -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<ProjectSectionProps> = ({ projects, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleProjects = projects?.filter((p) => p.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const flexLayout = globalSettings?.flexibleHeaderLayout;
return (
<SectionWrapper sectionId="projects" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="projects" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div layout="position">
<AnimatePresence mode="popLayout">
{visibleProjects.map((project) => (
<motion.div key={project.id} style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="flex items-center gap-2">
<div className={`flex items-center gap-2 ${flexLayout ? "" : "flex-[1.5]"}`}>
<h3 className="font-bold" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{project.name}</h3>
</div>
{project.link && !centerSubtitle && (
<a href={project.link.startsWith("http") ? project.link : `https://${project.link}`} target="_blank" rel="noopener noreferrer"
className={`underline ${flexLayout ? "" : "flex-1"}`} title={project.link}>
{(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()}
</a>
)}
{!project.link && !centerSubtitle && !flexLayout && <div className="flex-1" />}
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{project.role}
</motion.div>
)}
<div className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(project.date, locale)}
</div>
</motion.div>
{project.role && !centerSubtitle && (
<motion.div layout="position" className="text-subtitleFont" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{project.role}</motion.div>
)}
{project.link && centerSubtitle && (
<a href={project.link.startsWith("http") ? project.link : `https://${project.link}`} target="_blank" rel="noopener noreferrer" className="underline" title={project.link}>{project.link}</a>
)}
{project.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(project.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</motion.div>
</SectionWrapper>
);
};
export default ProjectSection;
@@ -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 (
<h3
className="pb-2 border-b font-bold"
style={{
fontSize: `${globalSettings?.headerSize || 18}px`,
color: themeColor,
borderColor: themeColor,
marginBottom: `${globalSettings?.paragraphSpacing}px`,
}}
>
{renderTitle}
</h3>
);
};
export default SectionTitle;
@@ -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 (
<SectionWrapper sectionId="skills" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="skills" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="text-baseFont" layout="position"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(skill) }}
/>
</motion.div>
</SectionWrapper>
);
};
export default SkillSection;
@@ -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",
},
};
@@ -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<CreativeTemplateProps> = ({ 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 <ExperienceSection experiences={data.experience} globalSettings={data.globalSettings} />;
case "education":
return <EducationSection education={data.education} globalSettings={data.globalSettings} />;
case "skills":
return <SkillSection skill={data.skillContent} globalSettings={data.globalSettings} />;
case "projects":
return <ProjectSection projects={data.projects} globalSettings={data.globalSettings} />;
default:
if (sectionId in data.customData) {
const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
return <CustomSection title={title} sectionId={sectionId} items={data.customData[sectionId]} globalSettings={data.globalSettings} />;
}
return null;
}
};
return (
<div className="flex flex-col w-full min-h-screen" style={{ backgroundColor: colorScheme.background, color: colorScheme.text }}>
{/* Top colored header block */}
{basicSection && (
<div className="w-full relative py-8 px-8 mb-6 rounded-b-3xl" style={{ backgroundColor: data.globalSettings.themeColor, color: "#ffffff" }}>
<div className="relative z-10 w-full">
<BaseInfo basic={data.basic} globalSettings={data.globalSettings} template={template} />
{data.basic.githubContributionsVisible && (
<GithubContribution className="mt-2 text-white" githubKey={data.basic.githubKey} username={data.basic.githubUseName} />
)}
</div>
</div>
)}
{/* Content sections */}
<div className="px-8 w-full w-max-4xl mx-auto">
{otherSections.map((section) => (
<div key={section.id}>{renderSection(section.id)}</div>
))}
</div>
</div>
);
};
export default CreativeTemplate;
@@ -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 ? <IconComponent className="w-4 h-4" /> : 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 && (
<motion.div layout="position">
<div style={{ width: `${basic.photoConfig?.width || 100}px`, height: `${basic.photoConfig?.height || 100}px`, borderRadius: getBorderRadiusValue(basic.photoConfig || { borderRadius: "none", customBorderRadius: 0 }), overflow: "hidden" }}>
<img src={basic.photo} alt={`${basic.name}'s photo`} className="w-full h-full object-cover" />
</div>
</motion.div>
);
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 (
<SectionWrapper sectionId="basic">
<div className={styles.container}>
<div className={styles.leftContent}>
{PhotoComponent}
<div className={cn("flex flex-col", styles.nameTitle)}>
{nameField.visible !== false && basic[nameField.key] && (
<motion.h1 layout="position" className="font-bold" style={{ fontSize: "30px" }}>{basic[nameField.key] as string}</motion.h1>
)}
{titleField.visible !== false && basic[titleField.key] && (
<motion.h2 layout="position" style={{ fontSize: "18px" }}>{basic[titleField.key] as string}</motion.h2>
)}
</div>
</div>
<motion.div layout="position" className={styles.fields}
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, color: "#fff", maxWidth: layout === "center" ? "none" : "600px" }}>
{allFields.map((item) => (
<motion.div key={item.key} className="flex items-center whitespace-nowrap overflow-hidden text-baseFont text-[#fff]">
{useIconMode ? (
<div className="flex items-center gap-1">
{getIcon(item.icon)}
{item.key === "email" ? <a href={`mailto:${item.value}`} className="underline">{item.value}</a> : <span>{item.value}</span>}
</div>
) : (
<div className="flex items-center gap-2 overflow-hidden">
{!item.custom && <span>{t(`basicPanel.basicFields.${item.key}`)}:</span>}
{item.custom && <span>{item.label}:</span>}
<span className="truncate" suppressHydrationWarning>{item.value}</span>
</div>
)}
</motion.div>
))}
</motion.div>
</div>
</SectionWrapper>
);
};
export default BaseInfo;
@@ -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 (
<SectionWrapper sectionId={sectionId} style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle title={title} type="custom" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleItems.map((item) => (
<motion.div key={item.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div layout="position" className="flex items-center gap-2">
<div className={`flex items-center gap-2 ${flexLayout ? "" : "flex-[1.5]"}`}>
<h4 className="font-bold" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{item.title}</h4>
</div>
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{item.subtitle}
</motion.div>
)}
<span className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(item.dateRange, locale)}
</span>
</motion.div>
{!centerSubtitle && item.subtitle && (
<motion.div layout="position" className="text-subtitleFont mt-1" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{item.subtitle}</motion.div>
)}
{item.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(item.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default CustomSection;
@@ -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 (
<SectionWrapper sectionId="education" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="education" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleEducation?.map((edu) => (
<motion.div key={edu.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div layout="position" className="flex items-center gap-2">
<div className={`font-bold ${flexLayout ? "" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{edu.school}
</div>
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
<span className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} suppressHydrationWarning
style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`}
</span>
</motion.div>
{!centerSubtitle && (
<motion.div layout="position" className="text-subtitleFont mt-1" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
{edu.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(edu.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default EducationSection;
@@ -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<ExperienceSectionProps> = ({ experiences, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleExperiences = experiences?.filter((exp) => exp.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const flexLayout = globalSettings?.flexibleHeaderLayout;
return (
<SectionWrapper sectionId="experience" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="experience" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleExperiences?.map((exp) => (
<motion.div key={exp.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="flex items-center gap-2">
<div className={`font-bold ${flexLayout ? "" : "flex-[1.5]"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{exp.company}
</div>
{centerSubtitle && (
<motion.div className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{exp.position}
</motion.div>
)}
<div className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(exp.date, locale)}
</div>
</motion.div>
{exp.position && !centerSubtitle && (
<motion.div className="text-subtitleFont" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{exp.position}</motion.div>
)}
{exp.details && (
<motion.div className="mt-2 text-baseFont" dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(exp.details) }}
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default ExperienceSection;
@@ -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<ProjectSectionProps> = ({ projects, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleProjects = projects?.filter((p) => p.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const flexLayout = globalSettings?.flexibleHeaderLayout;
return (
<SectionWrapper sectionId="projects" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="projects" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div layout="position">
<AnimatePresence mode="popLayout">
{visibleProjects.map((project) => (
<motion.div key={project.id} style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="flex items-center gap-2">
<div className={`flex items-center gap-2 ${flexLayout ? "" : "flex-[1.5]"}`}>
<h3 className="font-bold" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{project.name}</h3>
</div>
{project.link && !centerSubtitle && (
<a href={project.link.startsWith("http") ? project.link : `https://${project.link}`} target="_blank" rel="noopener noreferrer"
className={`underline ${flexLayout ? "" : "flex-1"}`} title={project.link}>
{(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()}
</a>
)}
{!project.link && !centerSubtitle && !flexLayout && <div className="flex-1" />}
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{project.role}
</motion.div>
)}
<div className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(project.date, locale)}
</div>
</motion.div>
{project.role && !centerSubtitle && (
<motion.div layout="position" className="text-subtitleFont" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{project.role}</motion.div>
)}
{project.link && centerSubtitle && (
<a href={project.link.startsWith("http") ? project.link : `https://${project.link}`} target="_blank" rel="noopener noreferrer" className="underline" title={project.link}>{project.link}</a>
)}
{project.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(project.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</motion.div>
</SectionWrapper>
);
};
export default ProjectSection;
@@ -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 (
<h3
className="inline-block px-3 py-1 rounded text-white shadow-sm mb-3 font-bold"
style={{
fontSize: `${globalSettings?.headerSize || 16}px`,
backgroundColor: themeColor,
color: "#ffffff",
marginBottom: `${globalSettings?.paragraphSpacing}px`,
}}
>
{renderTitle}
</h3>
);
};
export default SectionTitle;
@@ -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 (
<SectionWrapper sectionId="skills" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="skills" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="text-baseFont" layout="position"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(skill) }}
/>
</motion.div>
</SectionWrapper>
);
};
export default SkillSection;
@@ -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",
},
};
@@ -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<ElegantTemplateProps> = ({ 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 <BaseInfo basic={data.basic} globalSettings={data.globalSettings} template={template} />;
case "experience":
return <ExperienceSection experiences={data.experience} globalSettings={data.globalSettings} />;
case "education":
return <EducationSection education={data.education} globalSettings={data.globalSettings} />;
case "skills":
return <SkillSection skill={data.skillContent} globalSettings={data.globalSettings} />;
case "projects":
return <ProjectSection projects={data.projects} globalSettings={data.globalSettings} />;
default:
if (sectionId in data.customData) {
const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
return <CustomSection title={title} sectionId={sectionId} items={data.customData[sectionId]} globalSettings={data.globalSettings} />;
}
return null;
}
};
return (
<div className="flex flex-col w-full min-h-screen items-center" style={{ backgroundColor: colorScheme.background, color: colorScheme.text }}>
<div className="w-full max-w-4xl px-8">
{enabledSections.map((section) => (
<div key={section.id} className="w-full">{renderSection(section.id)}</div>
))}
</div>
</div>
);
};
export default ElegantTemplate;
@@ -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 ? <IconComponent className="w-4 h-4" /> : 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 && (
<motion.div layout="position">
<div style={{ width: `${basic.photoConfig?.width || 100}px`, height: `${basic.photoConfig?.height || 100}px`, borderRadius: getBorderRadiusValue(basic.photoConfig || { borderRadius: "none", customBorderRadius: 0 }), overflow: "hidden" }}>
<img src={basic.photo} alt={`${basic.name}'s photo`} className="w-full h-full object-cover" />
</div>
</motion.div>
);
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 (
<SectionWrapper sectionId="basic">
<div className={styles.container}>
<div className={styles.leftContent}>
{PhotoComponent}
<div className={cn("flex flex-col", styles.nameTitle)}>
{nameField.visible !== false && basic[nameField.key] && (
<motion.h1 layout="position" className="font-bold" style={{ fontSize: "30px" }}>{basic[nameField.key] as string}</motion.h1>
)}
{titleField.visible !== false && basic[titleField.key] && (
<motion.h2 layout="position" style={{ fontSize: "18px" }}>{basic[titleField.key] as string}</motion.h2>
)}
</div>
</div>
<motion.div layout="position" className={styles.fields} style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, color: "rgb(75, 85, 99)", maxWidth: layout === "center" ? "none" : "600px" }}>
{allFields.map((item) => (
<motion.div key={item.key} className="flex items-center whitespace-nowrap overflow-hidden text-baseFont">
{useIconMode ? (
<div className="flex items-center gap-1">
{getIcon(item.icon)}
{item.key === "email" ? <a href={`mailto:${item.value}`} className="underline">{item.value}</a> : <span>{item.value}</span>}
</div>
) : (
<div className="flex items-center gap-2 overflow-hidden">
{!item.custom && <span>{t(`basicPanel.basicFields.${item.key}`)}:</span>}
{item.custom && <span>{item.label}:</span>}
<span className="truncate" suppressHydrationWarning>{item.value}</span>
</div>
)}
</motion.div>
))}
</motion.div>
</div>
{basic.githubContributionsVisible && (
<GithubContribution className="mt-2" githubKey={basic.githubKey} username={basic.githubUseName} />
)}
</SectionWrapper>
);
};
export default BaseInfo;
@@ -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 (
<SectionWrapper sectionId={sectionId} style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle title={title} type="custom" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleItems.map((item) => (
<motion.div key={item.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div layout="position" className="flex items-center gap-2">
<div className={`flex items-center gap-2 ${flexLayout ? "" : "flex-[1.5]"}`}>
<h4 className="font-bold" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{item.title}</h4>
</div>
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{item.subtitle}
</motion.div>
)}
<span className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(item.dateRange, locale)}
</span>
</motion.div>
{!centerSubtitle && item.subtitle && (
<motion.div layout="position" className="text-subtitleFont mt-1" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{item.subtitle}</motion.div>
)}
{item.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(item.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default CustomSection;
@@ -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 (
<SectionWrapper sectionId="education" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="education" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleEducation?.map((edu) => (
<motion.div key={edu.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div layout="position" className="flex items-center gap-2">
<div className={`font-bold ${flexLayout ? "" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{edu.school}
</div>
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
<span className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} suppressHydrationWarning
style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`}
</span>
</motion.div>
{!centerSubtitle && (
<motion.div layout="position" className="text-subtitleFont mt-1" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
{edu.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(edu.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default EducationSection;
@@ -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<ExperienceSectionProps> = ({ experiences, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleExperiences = experiences?.filter((exp) => exp.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const flexLayout = globalSettings?.flexibleHeaderLayout;
return (
<SectionWrapper sectionId="experience" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="experience" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleExperiences?.map((exp) => (
<motion.div key={exp.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="flex items-center gap-2">
<div className={`font-bold ${flexLayout ? "" : "flex-[1.5]"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{exp.company}
</div>
{centerSubtitle && (
<motion.div className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{exp.position}
</motion.div>
)}
<div className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(exp.date, locale)}
</div>
</motion.div>
{exp.position && !centerSubtitle && (
<motion.div className="text-subtitleFont" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{exp.position}</motion.div>
)}
{exp.details && (
<motion.div className="mt-2 text-baseFont" dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(exp.details) }}
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default ExperienceSection;
@@ -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<ProjectSectionProps> = ({ projects, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleProjects = projects?.filter((p) => p.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const flexLayout = globalSettings?.flexibleHeaderLayout;
return (
<SectionWrapper sectionId="projects" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="projects" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div layout="position">
<AnimatePresence mode="popLayout">
{visibleProjects.map((project) => (
<motion.div key={project.id} style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="flex items-center gap-2">
<div className={`flex items-center gap-2 ${flexLayout ? "" : "flex-[1.5]"}`}>
<h3 className="font-bold" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{project.name}</h3>
</div>
{project.link && !centerSubtitle && (
<a href={project.link.startsWith("http") ? project.link : `https://${project.link}`} target="_blank" rel="noopener noreferrer"
className={`underline ${flexLayout ? "" : "flex-1"}`} title={project.link}>
{(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()}
</a>
)}
{!project.link && !centerSubtitle && !flexLayout && <div className="flex-1" />}
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{project.role}
</motion.div>
)}
<div className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(project.date, locale)}
</div>
</motion.div>
{project.role && !centerSubtitle && (
<motion.div layout="position" className="text-subtitleFont" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{project.role}</motion.div>
)}
{project.link && centerSubtitle && (
<a href={project.link.startsWith("http") ? project.link : `https://${project.link}`} target="_blank" rel="noopener noreferrer" className="underline" title={project.link}>{project.link}</a>
)}
{project.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(project.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</motion.div>
</SectionWrapper>
);
};
export default ProjectSection;
@@ -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 (
<div className="flex items-center justify-center w-full mb-4 relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t" style={{ borderColor: themeColor, opacity: 0.3 }} />
</div>
<h3
className="relative bg-white px-4 text-center font-bold"
style={{
fontSize: `${globalSettings?.headerSize || 20}px`,
color: themeColor,
}}
>
{renderTitle}
</h3>
</div>
);
};
export default SectionTitle;
@@ -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 (
<SectionWrapper sectionId="skills" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="skills" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="text-baseFont" layout="position"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(skill) }}
/>
</motion.div>
</SectionWrapper>
);
};
export default SkillSection;
+4 -31
View File
@@ -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<TemplateProps> = ({
data,
template
template,
}) => {
const renderTemplate = () => {
switch (template.layout) {
case "modern":
return <ModernTemplate data={data} template={template} />;
case "left-right":
return <LeftRightTemplate data={data} template={template} />;
case "timeline":
return <TimelineTemplate data={data} template={template} />;
case "minimalist":
return <MinimalistTemplate data={data} template={template} />;
case "elegant":
return <ElegantTemplate data={data} template={template} />;
case "creative":
return <CreativeTemplate data={data} template={template} />;
default:
return <ClassicTemplate data={data} template={template} />;
}
};
const TemplateComponent = getTemplateComponent(template.layout);
return (
<TemplateProvider templateId={template.id} menuSections={data.menuSections}>
{renderTemplate()}
<TemplateComponent data={data} template={template} />
</TemplateProvider>
);
};
@@ -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",
},
};
@@ -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<LeftRightTemplateProps> = ({ 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 <BaseInfo basic={data.basic} globalSettings={data.globalSettings} template={template} />;
case "experience":
return <ExperienceSection experiences={data.experience} globalSettings={data.globalSettings} />;
case "education":
return <EducationSection education={data.education} globalSettings={data.globalSettings} />;
case "skills":
return <SkillSection skill={data.skillContent} globalSettings={data.globalSettings} />;
case "projects":
return <ProjectSection projects={data.projects} globalSettings={data.globalSettings} />;
default:
if (sectionId in data.customData) {
const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
return <CustomSection title={title} sectionId={sectionId} items={data.customData[sectionId]} globalSettings={data.globalSettings} />;
}
return null;
}
};
return (
<div className="flex flex-col w-full" style={{ backgroundColor: colorScheme.background, color: colorScheme.text }}>
{enabledSections.map((section) => (
<div key={section.id}>{renderSection(section.id)}</div>
))}
</div>
);
};
export default LeftRightTemplate;
@@ -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 ? <IconComponent className="w-4 h-4" /> : 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 && (
<motion.div layout="position">
<div style={{ width: `${basic.photoConfig?.width || 100}px`, height: `${basic.photoConfig?.height || 100}px`, borderRadius: getBorderRadiusValue(basic.photoConfig || { borderRadius: "none", customBorderRadius: 0 }), overflow: "hidden" }}>
<img src={basic.photo} alt={`${basic.name}'s photo`} className="w-full h-full object-cover" />
</div>
</motion.div>
);
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 (
<SectionWrapper sectionId="basic">
<div className={styles.container}>
<div className={styles.leftContent}>
{PhotoComponent}
<div className={cn("flex flex-col", styles.nameTitle)}>
{nameField.visible !== false && basic[nameField.key] && (
<motion.h1 layout="position" className="font-bold" style={{ fontSize: "30px" }}>{basic[nameField.key] as string}</motion.h1>
)}
{titleField.visible !== false && basic[titleField.key] && (
<motion.h2 layout="position" style={{ fontSize: "18px" }}>{basic[titleField.key] as string}</motion.h2>
)}
</div>
</div>
<motion.div layout="position" className={styles.fields} style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, color: "rgb(75, 85, 99)", maxWidth: layout === "center" ? "none" : "600px" }}>
{allFields.map((item) => (
<motion.div key={item.key} className="flex items-center whitespace-nowrap overflow-hidden text-baseFont">
{useIconMode ? (
<div className="flex items-center gap-1">
{getIcon(item.icon)}
{item.key === "email" ? <a href={`mailto:${item.value}`} className="underline">{item.value}</a> : <span>{item.value}</span>}
</div>
) : (
<div className="flex items-center gap-2 overflow-hidden">
{!item.custom && <span>{t(`basicPanel.basicFields.${item.key}`)}:</span>}
{item.custom && <span>{item.label}:</span>}
<span className="truncate" suppressHydrationWarning>{item.value}</span>
</div>
)}
</motion.div>
))}
</motion.div>
</div>
{basic.githubContributionsVisible && (
<GithubContribution className="mt-2" githubKey={basic.githubKey} username={basic.githubUseName} />
)}
</SectionWrapper>
);
};
export default BaseInfo;
@@ -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 (
<SectionWrapper sectionId={sectionId} style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle title={title} type="custom" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleItems.map((item) => (
<motion.div key={item.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div layout="position" className="flex items-center gap-2">
<div className={`flex items-center gap-2 ${flexLayout ? "" : "flex-[1.5]"}`}>
<h4 className="font-bold" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{item.title}</h4>
</div>
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{item.subtitle}
</motion.div>
)}
<span className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(item.dateRange, locale)}
</span>
</motion.div>
{!centerSubtitle && item.subtitle && (
<motion.div layout="position" className="text-subtitleFont mt-1" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{item.subtitle}</motion.div>
)}
{item.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(item.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default CustomSection;
@@ -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 (
<SectionWrapper sectionId="education" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="education" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleEducation?.map((edu) => (
<motion.div key={edu.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div layout="position" className="flex items-center gap-2">
<div className={`font-bold ${flexLayout ? "" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{edu.school}
</div>
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
<span className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} suppressHydrationWarning
style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`}
</span>
</motion.div>
{!centerSubtitle && (
<motion.div layout="position" className="text-subtitleFont mt-1" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
{edu.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(edu.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default EducationSection;
@@ -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<ExperienceSectionProps> = ({ experiences, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleExperiences = experiences?.filter((exp) => exp.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const flexLayout = globalSettings?.flexibleHeaderLayout;
return (
<SectionWrapper sectionId="experience" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="experience" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleExperiences?.map((exp) => (
<motion.div key={exp.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="flex items-center gap-2">
<div className={`font-bold ${flexLayout ? "" : "flex-[1.5]"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{exp.company}
</div>
{centerSubtitle && (
<motion.div className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{exp.position}
</motion.div>
)}
<div className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(exp.date, locale)}
</div>
</motion.div>
{exp.position && !centerSubtitle && (
<motion.div className="text-subtitleFont" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{exp.position}</motion.div>
)}
{exp.details && (
<motion.div className="mt-2 text-baseFont" dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(exp.details) }}
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default ExperienceSection;
@@ -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<ProjectSectionProps> = ({ projects, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleProjects = projects?.filter((p) => p.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const flexLayout = globalSettings?.flexibleHeaderLayout;
return (
<SectionWrapper sectionId="projects" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="projects" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div layout="position">
<AnimatePresence mode="popLayout">
{visibleProjects.map((project) => (
<motion.div key={project.id} style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="flex items-center gap-2">
<div className={`flex items-center gap-2 ${flexLayout ? "" : "flex-[1.5]"}`}>
<h3 className="font-bold" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{project.name}</h3>
</div>
{project.link && !centerSubtitle && (
<a href={project.link.startsWith("http") ? project.link : `https://${project.link}`} target="_blank" rel="noopener noreferrer"
className={`underline ${flexLayout ? "" : "flex-1"}`} title={project.link}>
{(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()}
</a>
)}
{!project.link && !centerSubtitle && !flexLayout && <div className="flex-1" />}
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{project.role}
</motion.div>
)}
<div className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(project.date, locale)}
</div>
</motion.div>
{project.role && !centerSubtitle && (
<motion.div layout="position" className="text-subtitleFont" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{project.role}</motion.div>
)}
{project.link && centerSubtitle && (
<a href={project.link.startsWith("http") ? project.link : `https://${project.link}`} target="_blank" rel="noopener noreferrer" className="underline" title={project.link}>{project.link}</a>
)}
{project.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(project.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</motion.div>
</SectionWrapper>
);
};
export default ProjectSection;
@@ -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 (
<div className="relative">
<div className="absolute inset-0" style={{ backgroundColor: themeColor, opacity: 0.1 }} />
<h3
className="pl-4 py-1 flex items-center relative font-bold"
style={{
fontSize: `${globalSettings?.headerSize || 18}px`,
color: themeColor,
borderLeft: `3px solid ${themeColor}`,
marginBottom: `${globalSettings?.paragraphSpacing}px`,
}}
>
{renderTitle}
</h3>
</div>
);
};
export default SectionTitle;
@@ -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 (
<SectionWrapper sectionId="skills" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="skills" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="text-baseFont" layout="position"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(skill) }}
/>
</motion.div>
</SectionWrapper>
);
};
export default SkillSection;
@@ -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",
},
};
@@ -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<MinimalistTemplateProps> = ({ 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 <BaseInfo basic={data.basic} globalSettings={data.globalSettings} template={template} />;
case "experience":
return <ExperienceSection experiences={data.experience} globalSettings={data.globalSettings} />;
case "education":
return <EducationSection education={data.education} globalSettings={data.globalSettings} />;
case "skills":
return <SkillSection skill={data.skillContent} globalSettings={data.globalSettings} />;
case "projects":
return <ProjectSection projects={data.projects} globalSettings={data.globalSettings} />;
default:
if (sectionId in data.customData) {
const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
return <CustomSection title={title} sectionId={sectionId} items={data.customData[sectionId]} globalSettings={data.globalSettings} />;
}
return null;
}
};
return (
<div className="flex flex-col w-full min-h-screen" style={{ backgroundColor: colorScheme.background, color: colorScheme.text }}>
{enabledSections.map((section) => (
<div key={section.id} className="w-full">{renderSection(section.id)}</div>
))}
</div>
);
};
export default MinimalistTemplate;
@@ -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 ? <IconComponent className="w-4 h-4" /> : 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 && (
<motion.div layout="position">
<div style={{ width: `${basic.photoConfig?.width || 100}px`, height: `${basic.photoConfig?.height || 100}px`, borderRadius: getBorderRadiusValue(basic.photoConfig || { borderRadius: "none", customBorderRadius: 0 }), overflow: "hidden" }}>
<img src={basic.photo} alt={`${basic.name}'s photo`} className="w-full h-full object-cover" />
</div>
</motion.div>
);
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 (
<SectionWrapper sectionId="basic">
<div className={styles.container}>
<div className={styles.leftContent}>
{PhotoComponent}
<div className={cn("flex flex-col", styles.nameTitle)}>
{nameField.visible !== false && basic[nameField.key] && (
<motion.h1 layout="position" className="font-bold" style={{ fontSize: "30px" }}>{basic[nameField.key] as string}</motion.h1>
)}
{titleField.visible !== false && basic[titleField.key] && (
<motion.h2 layout="position" style={{ fontSize: "18px" }}>{basic[titleField.key] as string}</motion.h2>
)}
</div>
</div>
<motion.div layout="position" className={styles.fields} style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, color: "rgb(75, 85, 99)", maxWidth: layout === "center" ? "none" : "600px" }}>
{allFields.map((item) => (
<motion.div key={item.key} className="flex items-center whitespace-nowrap overflow-hidden text-baseFont">
{useIconMode ? (
<div className="flex items-center gap-1">
{getIcon(item.icon)}
{item.key === "email" ? <a href={`mailto:${item.value}`} className="underline">{item.value}</a> : <span>{item.value}</span>}
</div>
) : (
<div className="flex items-center gap-2 overflow-hidden">
{!item.custom && <span>{t(`basicPanel.basicFields.${item.key}`)}:</span>}
{item.custom && <span>{item.label}:</span>}
<span className="truncate" suppressHydrationWarning>{item.value}</span>
</div>
)}
</motion.div>
))}
</motion.div>
</div>
{basic.githubContributionsVisible && (
<GithubContribution className="mt-2" githubKey={basic.githubKey} username={basic.githubUseName} />
)}
</SectionWrapper>
);
};
export default BaseInfo;
@@ -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 (
<SectionWrapper sectionId={sectionId} style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle title={title} type="custom" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleItems.map((item) => (
<motion.div key={item.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div layout="position" className="flex items-center gap-2">
<div className={`flex items-center gap-2 ${flexLayout ? "" : "flex-[1.5]"}`}>
<h4 className="font-bold" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{item.title}</h4>
</div>
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{item.subtitle}
</motion.div>
)}
<span className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(item.dateRange, locale)}
</span>
</motion.div>
{!centerSubtitle && item.subtitle && (
<motion.div layout="position" className="text-subtitleFont mt-1" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{item.subtitle}</motion.div>
)}
{item.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(item.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default CustomSection;
@@ -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 (
<SectionWrapper sectionId="education" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="education" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleEducation?.map((edu) => (
<motion.div key={edu.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div layout="position" className="flex items-center gap-2">
<div className={`font-bold ${flexLayout ? "" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{edu.school}
</div>
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
<span className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} suppressHydrationWarning
style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`}
</span>
</motion.div>
{!centerSubtitle && (
<motion.div layout="position" className="text-subtitleFont mt-1" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
{edu.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(edu.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default EducationSection;
@@ -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<ExperienceSectionProps> = ({ experiences, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleExperiences = experiences?.filter((exp) => exp.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const flexLayout = globalSettings?.flexibleHeaderLayout;
return (
<SectionWrapper sectionId="experience" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="experience" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleExperiences?.map((exp) => (
<motion.div key={exp.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="flex items-center gap-2">
<div className={`font-bold ${flexLayout ? "" : "flex-[1.5]"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{exp.company}
</div>
{centerSubtitle && (
<motion.div className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{exp.position}
</motion.div>
)}
<div className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(exp.date, locale)}
</div>
</motion.div>
{exp.position && !centerSubtitle && (
<motion.div className="text-subtitleFont" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{exp.position}</motion.div>
)}
{exp.details && (
<motion.div className="mt-2 text-baseFont" dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(exp.details) }}
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default ExperienceSection;
@@ -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<ProjectSectionProps> = ({ projects, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleProjects = projects?.filter((p) => p.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const flexLayout = globalSettings?.flexibleHeaderLayout;
return (
<SectionWrapper sectionId="projects" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="projects" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div layout="position">
<AnimatePresence mode="popLayout">
{visibleProjects.map((project) => (
<motion.div key={project.id} style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="flex items-center gap-2">
<div className={`flex items-center gap-2 ${flexLayout ? "" : "flex-[1.5]"}`}>
<h3 className="font-bold" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{project.name}</h3>
</div>
{project.link && !centerSubtitle && (
<a href={project.link.startsWith("http") ? project.link : `https://${project.link}`} target="_blank" rel="noopener noreferrer"
className={`underline ${flexLayout ? "" : "flex-1"}`} title={project.link}>
{(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()}
</a>
)}
{!project.link && !centerSubtitle && !flexLayout && <div className="flex-1" />}
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{project.role}
</motion.div>
)}
<div className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(project.date, locale)}
</div>
</motion.div>
{project.role && !centerSubtitle && (
<motion.div layout="position" className="text-subtitleFont" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{project.role}</motion.div>
)}
{project.link && centerSubtitle && (
<a href={project.link.startsWith("http") ? project.link : `https://${project.link}`} target="_blank" rel="noopener noreferrer" className="underline" title={project.link}>{project.link}</a>
)}
{project.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(project.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</motion.div>
</SectionWrapper>
);
};
export default ProjectSection;
@@ -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 (
<h3
className="pb-1 mb-2 tracking-widest uppercase font-bold"
style={{
fontSize: `${globalSettings?.headerSize || 16}px`,
color: themeColor,
marginBottom: `${globalSettings?.paragraphSpacing}px`,
}}
>
{renderTitle}
</h3>
);
};
export default SectionTitle;
@@ -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 (
<SectionWrapper sectionId="skills" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="skills" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="text-baseFont" layout="position"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(skill) }}
/>
</motion.div>
</SectionWrapper>
);
};
export default SkillSection;
+23
View File
@@ -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",
},
};
+58
View File
@@ -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<ModernTemplateProps> = ({ 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 <BaseInfo basic={data.basic} globalSettings={data.globalSettings} template={template} />;
case "experience":
return <ExperienceSection experiences={data.experience} globalSettings={data.globalSettings} />;
case "education":
return <EducationSection education={data.education} globalSettings={data.globalSettings} />;
case "skills":
return <SkillSection skill={data.skillContent} globalSettings={data.globalSettings} />;
case "projects":
return <ProjectSection projects={data.projects} globalSettings={data.globalSettings} />;
default:
if (sectionId in data.customData) {
const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
return <CustomSection title={title} sectionId={sectionId} items={data.customData[sectionId]} globalSettings={data.globalSettings} />;
}
return null;
}
};
const basicSection = enabledSections.find((s) => s.id === "basic");
const otherSections = enabledSections.filter((s) => s.id !== "basic");
return (
<div className="grid grid-cols-3 w-full">
<div className="col-span-1 p-4" style={{ backgroundColor: data.globalSettings.themeColor, color: "#ffffff", paddingTop: data.globalSettings.sectionSpacing }}>
{basicSection && renderSection(basicSection.id)}
</div>
<div className="col-span-2 p-4 pt-0" style={{ backgroundColor: colorScheme.background, color: colorScheme.text }}>
{otherSections.map((section) => (
<div key={section.id}>{renderSection(section.id)}</div>
))}
</div>
</div>
);
};
export default ModernTemplate;
@@ -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 ? <IconComponent className="w-4 h-4" /> : 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 && (
<motion.div layout="position">
<div style={{ width: `${basic.photoConfig?.width || 100}px`, height: `${basic.photoConfig?.height || 100}px`, borderRadius: getBorderRadiusValue(basic.photoConfig || { borderRadius: "none", customBorderRadius: 0 }), overflow: "hidden" }}>
<img src={basic.photo} alt={`${basic.name}'s photo`} className="w-full h-full object-cover" />
</div>
</motion.div>
);
return (
<SectionWrapper sectionId="basic">
<div className="flex flex-col items-center gap-3">
<div className="flex flex-col items-center gap-4">
{PhotoComponent}
<div className="flex flex-col text-center">
{nameField.visible !== false && basic[nameField.key] && (
<motion.h1 layout="position" className="font-bold" style={{ fontSize: "30px" }}>{basic[nameField.key] as string}</motion.h1>
)}
{titleField.visible !== false && basic[titleField.key] && (
<motion.h2 layout="position" style={{ fontSize: "18px" }}>{basic[titleField.key] as string}</motion.h2>
)}
</div>
</div>
<motion.div layout="position" className="w-full flex flex-col gap-2"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, color: "#fff" }}>
{allFields.map((item) => (
<motion.div key={item.key} className="flex items-center whitespace-nowrap overflow-hidden text-baseFont text-[#fff]" style={{ width: "100%" }}>
{useIconMode ? (
<div className="flex items-center gap-1">
{getIcon(item.icon)}
{item.key === "email" ? <a href={`mailto:${item.value}`} className="underline">{item.value}</a> : <span>{item.value}</span>}
</div>
) : (
<div className="flex items-center gap-2 overflow-hidden">
{!item.custom && <span>{t(`basicPanel.basicFields.${item.key}`)}:</span>}
{item.custom && <span>{item.label}:</span>}
<span className="truncate" suppressHydrationWarning>{item.value}</span>
</div>
)}
</motion.div>
))}
</motion.div>
</div>
</SectionWrapper>
);
};
export default BaseInfo;
@@ -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 (
<SectionWrapper sectionId={sectionId} style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle title={title} type="custom" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleItems.map((item) => (
<motion.div key={item.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div layout="position" className="flex items-center gap-2">
<div className={`flex items-center gap-2 ${flexLayout ? "" : "flex-[1.5]"}`}>
<h4 className="font-bold" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{item.title}</h4>
</div>
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{item.subtitle}
</motion.div>
)}
<span className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(item.dateRange, locale)}
</span>
</motion.div>
{!centerSubtitle && item.subtitle && (
<motion.div layout="position" className="text-subtitleFont mt-1" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{item.subtitle}</motion.div>
)}
{item.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(item.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default CustomSection;
@@ -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 (
<SectionWrapper sectionId="education" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="education" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleEducation?.map((edu) => (
<motion.div key={edu.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div layout="position" className="flex items-center gap-2">
<div className={`font-bold ${flexLayout ? "" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{edu.school}
</div>
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
<span className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} suppressHydrationWarning
style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`}
</span>
</motion.div>
{!centerSubtitle && (
<motion.div layout="position" className="text-subtitleFont mt-1" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
{edu.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(edu.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default EducationSection;
@@ -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<ExperienceSectionProps> = ({ experiences, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleExperiences = experiences?.filter((exp) => exp.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const flexLayout = globalSettings?.flexibleHeaderLayout;
return (
<SectionWrapper sectionId="experience" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="experience" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleExperiences?.map((exp) => (
<motion.div key={exp.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="flex items-center gap-2">
<div className={`font-bold ${flexLayout ? "" : "flex-[1.5]"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{exp.company}
</div>
{centerSubtitle && (
<motion.div className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{exp.position}
</motion.div>
)}
<div className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(exp.date, locale)}
</div>
</motion.div>
{exp.position && !centerSubtitle && (
<motion.div className="text-subtitleFont" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{exp.position}</motion.div>
)}
{exp.details && (
<motion.div className="mt-2 text-baseFont" dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(exp.details) }}
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default ExperienceSection;
@@ -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<ProjectSectionProps> = ({ projects, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleProjects = projects?.filter((p) => p.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const flexLayout = globalSettings?.flexibleHeaderLayout;
return (
<SectionWrapper sectionId="projects" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="projects" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div layout="position">
<AnimatePresence mode="popLayout">
{visibleProjects.map((project) => (
<motion.div key={project.id} style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="flex items-center gap-2">
<div className={`flex items-center gap-2 ${flexLayout ? "" : "flex-[1.5]"}`}>
<h3 className="font-bold" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{project.name}</h3>
</div>
{project.link && !centerSubtitle && (
<a href={project.link.startsWith("http") ? project.link : `https://${project.link}`} target="_blank" rel="noopener noreferrer"
className={`underline ${flexLayout ? "" : "flex-1"}`} title={project.link}>
{(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()}
</a>
)}
{!project.link && !centerSubtitle && !flexLayout && <div className="flex-1" />}
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{project.role}
</motion.div>
)}
<div className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(project.date, locale)}
</div>
</motion.div>
{project.role && !centerSubtitle && (
<motion.div layout="position" className="text-subtitleFont" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{project.role}</motion.div>
)}
{project.link && centerSubtitle && (
<a href={project.link.startsWith("http") ? project.link : `https://${project.link}`} target="_blank" rel="noopener noreferrer" className="underline" title={project.link}>{project.link}</a>
)}
{project.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(project.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</motion.div>
</SectionWrapper>
);
};
export default ProjectSection;
@@ -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 (
<h3
className="border-b pb-2 font-semibold mb-2 uppercase tracking-wider"
style={{
fontSize: `${globalSettings?.headerSize || 18}px`,
fontWeight: "bold",
color: themeColor,
borderColor: themeColor,
marginBottom: `${globalSettings?.paragraphSpacing}px`,
}}
>
{renderTitle}
</h3>
);
};
export default SectionTitle;
@@ -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 (
<SectionWrapper sectionId="skills" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="skills" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="text-baseFont" layout="position"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(skill) }}
/>
</motion.div>
</SectionWrapper>
);
};
export default SkillSection;
+55
View File
@@ -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
);
}
@@ -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<SectionWrapperProps> = ({
sectionId,
children,
className = "",
style,
}) => {
const { setActiveSection } = useResumeStore();
return (
<motion.div
className={`hover:cursor-pointer hover:bg-gray-100 rounded-md transition-all duration-300 ease-in-out hover:shadow-md ${className}`}
style={style}
onClick={() => setActiveSection(sectionId)}
>
{children}
</motion.div>
);
};
export default SectionWrapper;
@@ -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",
},
};
@@ -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<TimelineTemplateProps> = ({ 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) => (
<div className="relative pl-6">
<div className="absolute left-0 top-2 h-full w-0.5" style={{ backgroundColor: "#e5e7eb" }} />
<div className="absolute left-[-6px] top-2 w-3 h-3 rounded-full" style={{ backgroundColor: colorScheme.primary }} />
<div className="text-xl font-bold mb-4" style={{ color: data.globalSettings.themeColor, fontSize: `${data.globalSettings.headerSize || 20}px` }}>
{title}
</div>
<div>{content}</div>
</div>
);
const renderSection = (sectionId: string) => {
switch (sectionId) {
case "basic":
return <BaseInfo basic={data.basic} globalSettings={data.globalSettings} template={template} />;
case "experience":
return <ExperienceSection experiences={data.experience} globalSettings={data.globalSettings} showTitle={false} />;
case "education":
return <EducationSection education={data.education} globalSettings={data.globalSettings} showTitle={false} />;
case "skills":
return <SkillSection skill={data.skillContent} globalSettings={data.globalSettings} showTitle={false} />;
case "projects":
return <ProjectSection projects={data.projects} globalSettings={data.globalSettings} showTitle={false} />;
default:
if (sectionId in data.customData) {
const title = data.menuSections.find((s) => s.id === sectionId)?.title || sectionId;
return <CustomSection title={title} sectionId={sectionId} items={data.customData[sectionId]} globalSettings={data.globalSettings} showTitle={false} />;
}
return null;
}
};
return (
<div className="flex flex-col w-full min-h-screen pl-[6px]" style={{ backgroundColor: colorScheme.background, color: colorScheme.text }}>
{enabledSections.map((section) => {
if (section.id === "basic") {
return <div key={section.id} className="mb-4">{renderSection(section.id)}</div>;
}
const sectionTitle = data.menuSections.find((s) => s.id === section.id)?.title || section.id;
return (
<div key={section.id} className="mb-4">
<div className="timeline-section">
{renderTimelineItem(renderSection(section.id), sectionTitle)}
</div>
</div>
);
})}
</div>
);
};
export default TimelineTemplate;
@@ -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 ? <IconComponent className="w-4 h-4" /> : 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 && (
<motion.div layout="position">
<div style={{ width: `${basic.photoConfig?.width || 100}px`, height: `${basic.photoConfig?.height || 100}px`, borderRadius: getBorderRadiusValue(basic.photoConfig || { borderRadius: "none", customBorderRadius: 0 }), overflow: "hidden" }}>
<img src={basic.photo} alt={`${basic.name}'s photo`} className="w-full h-full object-cover" />
</div>
</motion.div>
);
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 (
<SectionWrapper sectionId="basic">
<div className={styles.container}>
<div className={styles.leftContent}>
{PhotoComponent}
<div className={cn("flex flex-col", styles.nameTitle)}>
{nameField.visible !== false && basic[nameField.key] && (
<motion.h1 layout="position" className="font-bold" style={{ fontSize: "30px" }}>{basic[nameField.key] as string}</motion.h1>
)}
{titleField.visible !== false && basic[titleField.key] && (
<motion.h2 layout="position" style={{ fontSize: "18px" }}>{basic[titleField.key] as string}</motion.h2>
)}
</div>
</div>
<motion.div layout="position" className={styles.fields} style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, color: "rgb(75, 85, 99)", maxWidth: layout === "center" ? "none" : "600px" }}>
{allFields.map((item) => (
<motion.div key={item.key} className="flex items-center whitespace-nowrap overflow-hidden text-baseFont">
{useIconMode ? (
<div className="flex items-center gap-1">
{getIcon(item.icon)}
{item.key === "email" ? <a href={`mailto:${item.value}`} className="underline">{item.value}</a> : <span>{item.value}</span>}
</div>
) : (
<div className="flex items-center gap-2 overflow-hidden">
{!item.custom && <span>{t(`basicPanel.basicFields.${item.key}`)}:</span>}
{item.custom && <span>{item.label}:</span>}
<span className="truncate" suppressHydrationWarning>{item.value}</span>
</div>
)}
</motion.div>
))}
</motion.div>
</div>
{basic.githubContributionsVisible && (
<GithubContribution className="mt-2" githubKey={basic.githubKey} username={basic.githubUseName} />
)}
</SectionWrapper>
);
};
export default BaseInfo;
@@ -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 (
<SectionWrapper sectionId={sectionId} style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle title={title} type="custom" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleItems.map((item) => (
<motion.div key={item.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div layout="position" className="flex items-center gap-2">
<div className={`flex items-center gap-2 ${flexLayout ? "" : "flex-[1.5]"}`}>
<h4 className="font-bold" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{item.title}</h4>
</div>
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{item.subtitle}
</motion.div>
)}
<span className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(item.dateRange, locale)}
</span>
</motion.div>
{!centerSubtitle && item.subtitle && (
<motion.div layout="position" className="text-subtitleFont mt-1" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{item.subtitle}</motion.div>
)}
{item.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(item.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default CustomSection;
@@ -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 (
<SectionWrapper sectionId="education" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="education" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleEducation?.map((edu) => (
<motion.div key={edu.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div layout="position" className="flex items-center gap-2">
<div className={`font-bold ${flexLayout ? "" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{edu.school}
</div>
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
<span className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} suppressHydrationWarning
style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{`${formatDateString(edu.startDate, locale)} - ${formatDateString(edu.endDate, locale)}`}
</span>
</motion.div>
{!centerSubtitle && (
<motion.div layout="position" className="text-subtitleFont mt-1" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{[edu.major, edu.degree].filter(Boolean).join(" · ")}
{edu.gpa && ` · GPA ${edu.gpa}`}
</motion.div>
)}
{edu.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(edu.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default EducationSection;
@@ -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<ExperienceSectionProps> = ({ experiences, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleExperiences = experiences?.filter((exp) => exp.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const flexLayout = globalSettings?.flexibleHeaderLayout;
return (
<SectionWrapper sectionId="experience" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="experience" globalSettings={globalSettings} showTitle={showTitle} />
<AnimatePresence mode="popLayout">
{visibleExperiences?.map((exp) => (
<motion.div key={exp.id} layout="position" style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="flex items-center gap-2">
<div className={`font-bold ${flexLayout ? "" : "flex-[1.5]"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{exp.company}
</div>
{centerSubtitle && (
<motion.div className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{exp.position}
</motion.div>
)}
<div className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(exp.date, locale)}
</div>
</motion.div>
{exp.position && !centerSubtitle && (
<motion.div className="text-subtitleFont" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{exp.position}</motion.div>
)}
{exp.details && (
<motion.div className="mt-2 text-baseFont" dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(exp.details) }}
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</SectionWrapper>
);
};
export default ExperienceSection;
@@ -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<ProjectSectionProps> = ({ projects, globalSettings, showTitle = true }) => {
const locale = useLocale();
const visibleProjects = projects?.filter((p) => p.visible);
const centerSubtitle = globalSettings?.centerSubtitle;
const flexLayout = globalSettings?.flexibleHeaderLayout;
return (
<SectionWrapper sectionId="projects" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="projects" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div layout="position">
<AnimatePresence mode="popLayout">
{visibleProjects.map((project) => (
<motion.div key={project.id} style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="flex items-center gap-2">
<div className={`flex items-center gap-2 ${flexLayout ? "" : "flex-[1.5]"}`}>
<h3 className="font-bold" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{project.name}</h3>
</div>
{project.link && !centerSubtitle && (
<a href={project.link.startsWith("http") ? project.link : `https://${project.link}`} target="_blank" rel="noopener noreferrer"
className={`underline ${flexLayout ? "" : "flex-1"}`} title={project.link}>
{(() => { try { return new URL(project.link.startsWith("http") ? project.link : `https://${project.link}`).hostname.replace(/^www\./, ""); } catch { return project.link; } })()}
</a>
)}
{!project.link && !centerSubtitle && !flexLayout && <div className="flex-1" />}
{centerSubtitle && (
<motion.div layout="position" className={`text-subtitleFont ${flexLayout ? "ml-[16px]" : "flex-1"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{project.role}
</motion.div>
)}
<div className={`text-subtitleFont shrink-0 ${flexLayout ? "ml-auto" : "flex-1 text-right"}`} style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>
{formatDateString(project.date, locale)}
</div>
</motion.div>
{project.role && !centerSubtitle && (
<motion.div layout="position" className="text-subtitleFont" style={{ fontSize: `${globalSettings?.subheaderSize || 16}px` }}>{project.role}</motion.div>
)}
{project.link && centerSubtitle && (
<a href={project.link.startsWith("http") ? project.link : `https://${project.link}`} target="_blank" rel="noopener noreferrer" className="underline" title={project.link}>{project.link}</a>
)}
{project.description && (
<motion.div layout="position" className="mt-2 text-baseFont"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(project.description) }}
/>
)}
</motion.div>
))}
</AnimatePresence>
</motion.div>
</SectionWrapper>
);
};
export default ProjectSection;
@@ -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 (
<div
className="text-xl font-bold mb-4"
style={{
color: themeColor,
fontSize: `${globalSettings?.headerSize || 20}px`,
}}
>
{renderTitle}
</div>
);
};
export default SectionTitle;
@@ -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 (
<SectionWrapper sectionId="skills" style={{ marginTop: `${globalSettings?.sectionSpacing || 24}px` }}>
<SectionTitle type="skills" globalSettings={globalSettings} showTitle={showTitle} />
<motion.div style={{ marginTop: `${globalSettings?.paragraphSpacing}px` }}>
<motion.div className="text-baseFont" layout="position"
style={{ fontSize: `${globalSettings?.baseFontSize || 14}px`, lineHeight: globalSettings?.lineHeight || 1.6 }}
dangerouslySetInnerHTML={{ __html: normalizeRichTextContent(skill) }}
/>
</motion.div>
</SectionWrapper>
);
};
export default SkillSection;
+3 -152
View File
@@ -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 = {
-70
View File
@@ -1,70 +0,0 @@
import { TemplateConfig } from "@/types/template";
export const templateConfigs: Record<string, TemplateConfig> = {
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)",
},
},
},
};
+1 -1
View File
@@ -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;