mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-07-03 14:07:11 +02:00
refactor: reorganize template system
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user