feat: Implement live template previews in selection and remove Compact and Professional templates.

This commit is contained in:
JOYCEQL
2026-02-25 01:40:57 +08:00
parent 3a3d9de6f8
commit 65912e4a2f
11 changed files with 56 additions and 406 deletions
+6 -2
View File
@@ -40,6 +40,10 @@ const BaseInfo = ({
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 [
@@ -221,14 +225,14 @@ const BaseInfo = ({
className={fieldsContainerClass}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
color: isModernTemplate ? "#fff" : "rgb(75, 85, 99)",
color: isWhiteTextTemplate ? "#fff" : "rgb(75, 85, 99)",
maxWidth: layout === "center" ? "none" : "600px",
}}
>
{allFields.map((item) => (
<motion.div
key={item.key}
className={cn(baseFieldItemClass, isModernTemplate && "text-[#fff]")}
className={cn(baseFieldItemClass, isWhiteTextTemplate && "text-[#fff]")}
style={{
width: isModernTemplate ? "100%" : "",
}}
+2 -26
View File
@@ -115,19 +115,7 @@ const SectionTitle = ({ type, title, globalSettings, showTitle = true }: Section
</h3>
);
case "professional":
return (
<h3
className={cn("pb-1 border-b-2 uppercase tracking-wide")}
style={{
...baseStyles,
color: themeColor,
borderColor: themeColor,
}}
>
{renderTitle}
</h3>
);
case "elegant":
return (
@@ -168,19 +156,7 @@ const SectionTitle = ({ type, title, globalSettings, showTitle = true }: Section
</h3>
);
case "compact":
return (
<h3
className={cn("pb-1 border-b border-dashed mb-2")}
style={{
...baseStyles,
color: themeColor,
borderColor: themeColor,
}}
>
{renderTitle}
</h3>
);
default:
return (
+38 -37
View File
@@ -13,17 +13,7 @@ import {
import { cn } from "@/lib/utils";
import { DEFAULT_TEMPLATES } from "@/config";
import { useResumeStore } from "@/store/useResumeStore";
import classic from "@/assets/images/template-cover/classic.png";
import modern from "@/assets/images/template-cover/modern.png";
import leftRight from "@/assets/images/template-cover/left-right.png";
import timeline from "@/assets/images/template-cover/timeline.png";
const templateImages: Record<string, string> = {
classic,
modern,
"left-right": leftRight,
timeline
};
import { ScrollArea } from "@/components/ui/scroll-area";
const TemplateSheet = () => {
const t = useTranslations("templates");
@@ -45,33 +35,44 @@ const TemplateSheet = () => {
{/* 解决警告问题 */}
<SheetDescription></SheetDescription>
<div className="grid grid-cols-3 gap-4 mt-4">
{DEFAULT_TEMPLATES.map((template) => (
<button
key={template.id}
onClick={() => setTemplate(template.id)}
className={cn(
"relative group rounded-lg overflow-hidden border-2 transition-all duration-200 hover:scale-[1.02]",
template.id === currentTemplate.id
? "border-primary dark:border-primary shadow-lg dark:shadow-primary/30"
: "dark:border-neutral-800 dark:hover:border-neutral-700 border-gray-100 hover:border-gray-200"
)}
>
<img
src={templateImages[template.id]}
alt={template.name}
className="w-full h-auto"
/>
{template.id === currentTemplate.id && (
<motion.div
layoutId="template-selected"
className="absolute inset-0 flex items-center justify-center bg-black/20 dark:bg-white/30"
<div className="h-[calc(100vh-8rem)] mt-4">
<ScrollArea className="h-full w-full pr-4">
<div className="grid grid-cols-4 gap-4 pb-8">
{DEFAULT_TEMPLATES.map((template) => (
<button
key={template.id}
onClick={() => setTemplate(template.id)}
className={cn(
"relative group rounded-lg overflow-hidden border-2 transition-all duration-200 hover:scale-[1.02]",
template.id === currentTemplate.id
? "border-primary dark:border-primary shadow-lg dark:shadow-primary/30"
: "dark:border-neutral-800 dark:hover:border-neutral-700 border-gray-100 hover:border-gray-200"
)}
>
<Layout className="w-6 h-6 text-white dark:text-primary" />
</motion.div>
)}
</button>
))}
<div className="relative aspect-[210/297] w-full overflow-hidden bg-white pointer-events-none">
<svg viewBox="0 0 794 1123" className="absolute inset-0 w-full h-full pointer-events-none rounded-md">
<foreignObject x="0" y="0" width="794" height="1123">
<iframe
src={`/app/preview-template/${template.id}`}
className="w-[794px] h-[1123px] border-0 pointer-events-none"
scrolling="no"
tabIndex={-1}
/>
</foreignObject>
</svg>
</div>
{template.id === currentTemplate.id && (
<motion.div
layoutId="template-selected"
className="absolute inset-0 flex items-center justify-center bg-black/10 dark:bg-black/40 pointer-events-none z-20"
>
<Layout className="w-8 h-8 text-primary shadow-sm" />
</motion.div>
)}
</button>
))}
</div>
</ScrollArea>
</div>
</SheetContent>
</Sheet>
@@ -1,129 +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 CompactTemplateProps {
data: ResumeData;
template: ResumeTemplate;
}
const CompactTemplate: React.FC<CompactTemplateProps> = ({
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="border-b-2 border-gray-800 pb-2 mb-4">
<BaseInfo basic={data.basic} globalSettings={data.globalSettings} template={template} />
{data.basic.githubContributionsVisible && (
<GithubContribution
className="mt-1"
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;
}
};
const basicSection = sortedSections.find((section) => section.id === "basic");
const otherSections = sortedSections.filter(
(section) => section.id !== "basic"
);
// 紧凑排版:可以分为两列
const leftSections = otherSections.filter((_, i) => i % 2 === 0);
const rightSections = otherSections.filter((_, i) => i % 2 !== 0);
return (
<div
className="flex flex-col w-full min-h-screen relative"
style={{
backgroundColor: colorScheme.background,
color: colorScheme.text,
}}
>
{/* 顶部个人信息占满全宽 */}
{basicSection && <div className="w-full">{renderSection(basicSection.id)}</div>}
{/* 瀑布流/双列紧凑排版 */}
<div className="grid grid-cols-2 gap-4 w-full">
<div className="flex flex-col">
{leftSections.map((section) => (
<div key={section.id} className="w-full">
{renderSection(section.id)}
</div>
))}
</div>
<div className="flex flex-col">
{rightSections.map((section) => (
<div key={section.id} className="w-full">
{renderSection(section.id)}
</div>
))}
</div>
</div>
</div>
);
};
export default CompactTemplate;
@@ -1,129 +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 ProfessionalTemplateProps {
data: ResumeData;
template: ResumeTemplate;
}
const ProfessionalTemplate: React.FC<ProfessionalTemplateProps> = ({ 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;
}
};
const leftColumnSections = ["basic", "skills", "education"];
const rightColumnSections = ["experience", "projects"];
const leftSectionsToRender = sortedSections.filter(s => leftColumnSections.includes(s.id));
const rightSectionsToRender = sortedSections.filter(s => rightColumnSections.includes(s.id));
// Custom sections go to the right by default
const customSections = sortedSections.filter(s => !leftColumnSections.includes(s.id) && !rightColumnSections.includes(s.id));
return (
<div className="grid grid-cols-12 w-full min-h-screen" style={{ backgroundColor: colorScheme.background, color: colorScheme.text }}>
{/* 左侧区域(偏窄) */}
<div
className="col-span-4 p-6 border-r border-gray-200"
style={{
paddingTop: data.globalSettings.sectionSpacing,
}}
>
{leftSectionsToRender.map((section) => (
<div key={section.id}>{renderSection(section.id)}</div>
))}
</div>
{/* 右侧区域(偏宽) */}
<div
className="col-span-8 p-6"
style={{
paddingTop: data.globalSettings.sectionSpacing,
}}
>
{rightSectionsToRender.map((section) => (
<div key={section.id}>{renderSection(section.id)}</div>
))}
{customSections.map((section) => (
<div key={section.id}>{renderSection(section.id)}</div>
))}
</div>
</div>
);
};
export default ProfessionalTemplate;
+4 -6
View File
@@ -4,10 +4,10 @@ import ModernTemplate from "./ModernTemplate";
import LeftRightTemplate from "./LeftRightTemplate";
import TimelineTemplate from "./TimelineTemplate";
import MinimalistTemplate from "./MinimalistTemplate";
import ProfessionalTemplate from "./ProfessionalTemplate";
import ElegantTemplate from "./ElegantTemplate";
import CreativeTemplate from "./CreativeTemplate";
import CompactTemplate from "./CompactTemplate";
import { TemplateProvider } from "./TemplateContext";
import { ResumeData } from "@/types/resume";
import { ResumeTemplate } from "@/types/template";
@@ -31,14 +31,12 @@ const ResumeTemplateComponent: React.FC<TemplateProps> = ({
return <TimelineTemplate data={data} template={template} />;
case "minimalist":
return <MinimalistTemplate data={data} template={template} />;
case "professional":
return <ProfessionalTemplate data={data} template={template} />;
case "elegant":
return <ElegantTemplate data={data} template={template} />;
case "creative":
return <CreativeTemplate data={data} template={template} />;
case "compact":
return <CompactTemplate data={data} template={template} />;
default:
return <ClassicTemplate data={data} template={template} />;
}
+1 -42
View File
@@ -123,27 +123,7 @@ export const DEFAULT_TEMPLATES: ResumeTemplate[] = [
layout: "center"
}
},
{
id: "professional",
name: "商务模板",
description: "经典双列布局,适合资深职场人士",
thumbnail: "professional",
layout: "professional",
colorScheme: {
primary: "#0f172a",
secondary: "#475569",
background: "#ffffff",
text: "#1e293b"
},
spacing: {
sectionGap: 20,
itemGap: 16,
contentPadding: 24
},
basic: {
layout: "left"
}
},
{
id: "elegant",
name: "优雅模板",
@@ -185,27 +165,6 @@ export const DEFAULT_TEMPLATES: ResumeTemplate[] = [
basic: {
layout: "left"
}
},
{
id: "compact",
name: "紧凑模板",
description: "高效的页面利用率,适合经验丰富的信息传达",
thumbnail: "compact",
layout: "compact",
colorScheme: {
primary: "#000000",
secondary: "#6b7280",
background: "#ffffff",
text: "#111827"
},
spacing: {
sectionGap: 12,
itemGap: 8,
contentPadding: 16
},
basic: {
layout: "left"
}
}
];
+2 -18
View File
@@ -47,15 +47,7 @@ export const templateConfigs: Record<string, TemplateConfig> = {
},
},
},
professional: {
sectionTitle: {
className: "border-b-2 mb-3 pb-1 uppercase tracking-wider",
styles: {
fontSize: 18,
borderColor: "var(--theme-color)",
},
},
},
elegant: {
sectionTitle: {
className: "flex items-center justify-center w-full mb-4 relative",
@@ -73,14 +65,6 @@ export const templateConfigs: Record<string, TemplateConfig> = {
},
},
},
compact: {
sectionTitle: {
className: "border-b border-dashed mb-2 pb-1",
styles: {
fontSize: 15,
borderColor: "var(--theme-color)",
},
},
},
};
+1 -8
View File
@@ -186,10 +186,7 @@
"name": "Minimalist",
"description": "Large amount of whitespace, clean and pure layout style"
},
"professional": {
"name": "Professional",
"description": "Classic two-column layout, suitable for senior professionals"
},
"elegant": {
"name": "Elegant",
"description": "Centered title single-column design, with a touch of elegance"
@@ -197,10 +194,6 @@
"creative": {
"name": "Creative",
"description": "Visual contrast design, vibrant and personalized"
},
"compact": {
"name": "Compact",
"description": "Efficient page utilization, suitable for extensive experiences"
}
}
},
+1 -8
View File
@@ -187,10 +187,7 @@
"name": "极简模板",
"description": "大面积留白,干净纯粹的排版风格"
},
"professional": {
"name": "商务模板",
"description": "经典双列布局,适合资深职场人士"
},
"elegant": {
"name": "优雅模板",
"description": "居中标题单列设计,具有高级感的分隔线"
@@ -198,10 +195,6 @@
"creative": {
"name": "创意模板",
"description": "视觉错落设计,灵动活泼展现个性"
},
"compact": {
"name": "紧凑模板",
"description": "高效的页面利用率,适合经验丰富的信息传达"
}
}
},
+1 -1
View File
@@ -5,7 +5,7 @@ export interface ResumeTemplate {
name: string;
description: string;
thumbnail: string;
layout: "classic" | "modern" | "left-right" | "professional" | "timeline" | "minimalist" | "elegant" | "creative" | "compact";
layout: "classic" | "modern" | "left-right" | "timeline" | "minimalist" | "elegant" | "creative";
colorScheme: {
primary: string;
secondary: string;