feat: custom section

This commit is contained in:
JOYCEQL
2024-11-22 14:04:23 +08:00
parent 1f9bf22f1c
commit 008c64639d
11 changed files with 624 additions and 13 deletions
@@ -8,6 +8,7 @@ import BasicPanel from "./basic/BasicPanel";
import EducationPanel from "./education/EducationPanel";
import ProjectPanel from "./project/ProjectPanel";
import ExperiencePanel from "./experience/ExperiencePanel";
import CustomPanel from "./custom/CustomPanel";
export function EditPanel() {
const { theme, activeSection, menuSections } = useResumeStore();
@@ -24,8 +25,13 @@ export function EditPanel() {
case "experience":
return <ExperiencePanel />;
case "skills":
default:
return null;
default:
if (activeSection.startsWith("custom")) {
return <CustomPanel sectionId={activeSection} />;
} else {
return <BasicPanel />;
}
}
};
@@ -9,7 +9,8 @@ import {
Type,
SpaceIcon,
Palette,
Plus
Plus,
Trash2
} from "lucide-react";
import { useResumeStore } from "@/store/useResumeStore";
import {
@@ -92,7 +93,9 @@ export function SidePanel() {
globalSettings,
updateGlobalSettings,
colorTheme,
setColorTheme
setColorTheme,
updateMenuSections,
addCustomData
} = useResumeStore();
const debouncedSetColor = useMemo(
@@ -103,6 +106,27 @@ export function SidePanel() {
[]
);
const generateCustomSectionId = (menuSections: any[]) => {
const customSections = menuSections.filter((s) =>
s.id.startsWith("custom")
);
const nextNum = customSections.length + 1;
return `custom-${nextNum}`;
};
const handleCreateSection = () => {
const sectionId = generateCustomSectionId(menuSections);
const newSection = {
id: sectionId,
title: sectionId,
icon: "",
enabled: true,
order: menuSections.length
};
updateMenuSections([...menuSections, newSection]);
addCustomData(sectionId);
};
return (
<motion.div
className={cn(
@@ -183,10 +207,41 @@ export function SidePanel() {
<EyeOff className="w-4 h-4" />
)}
</motion.button>
{/* 删除按钮 */}
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => {
updateMenuSections(
menuSections.filter((section) => section.id !== item.id)
);
setActiveSection(menuSections[0].id);
}}
className={cn(
"p-1.5 rounded-md text-primary",
theme === "dark"
? "hover:bg-neutral-700 text-neutral-300"
: "hover:bg-gray-100 text-gray-600"
)}
>
<Trash2 className="w-4 h-4 text-red-400" />
</motion.button>
</div>
</Reorder.Item>
))}
</Reorder.Group>
{/* 添加自定义模块 */}
<div className="space-y-2 p-[16px]">
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.9 }}
onClick={handleCreateSection}
className="flex justify-center w-full rounded-lg items-center gap-2 py-2 px-3 text-sm font-medium text-indigo-600 bg-indigo-50"
>
</motion.button>
</div>
</SettingCard>
{/* 主题色设置 */}
@@ -0,0 +1,290 @@
import { useCallback, useState } from "react";
import {
motion,
useDragControls,
Reorder,
AnimatePresence
} from "framer-motion";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useResumeStore } from "@/store/useResumeStore";
import { GripVertical, Eye, EyeOff, ChevronDown, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from "@/components/ui/alert-dialog";
import Field from "../Field";
import { CustomItem as CustomItemType } from "@/types/resume";
const CustomItemEditor = ({
item,
onSave
}: {
item: CustomItemType;
onSave: (item: CustomItemType) => void;
}) => {
const handleChange = (field: keyof CustomItemType, value: string) => {
onSave({ ...item, [field]: value });
};
return (
<div className="space-y-5">
<div className="grid gap-5">
<div className="grid grid-cols-2 gap-4">
<Field
label="标题"
value={item.title}
onChange={(value) => handleChange("title", value)}
placeholder="标题"
/>
<Field
label="副标题"
value={item.subtitle}
onChange={(value) => handleChange("subtitle", value)}
placeholder="副标题"
/>
</div>
<Field
label="时间范围"
value={item.dateRange}
onChange={(value) => handleChange("dateRange", value)}
placeholder="例如: 2023.01 - 2024.01"
/>
<Field
label="详细描述"
value={item.description}
onChange={(value) => handleChange("description", value)}
type="editor"
placeholder="请输入详细描述..."
/>
</div>
</div>
);
};
const DeleteConfirmDialog = ({
title,
onDelete
}: {
title: string;
onDelete: () => void;
}) => {
const theme = useResumeStore((state) => state.theme);
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"text-sm",
theme === "dark"
? "hover:bg-red-900/50 text-red-400"
: "hover:bg-red-50 text-red-600"
)}
onClick={(e) => e.stopPropagation()}
>
<Trash2 className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent
className={cn(
theme === "dark" ? "bg-neutral-900 border-neutral-800" : "bg-white"
)}
onClick={(e) => e.stopPropagation()}
>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{title || "此项"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={onDelete}
className="bg-red-600 hover:bg-red-700 text-white"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
const CustomItem = ({
item,
sectionId
}: {
item: CustomItemType;
sectionId: string;
}) => {
const dragControls = useDragControls();
const [expandedId, setExpandedId] = useState<string | null>(null);
const { theme, updateCustomItem, removeCustomItem } = useResumeStore();
const [isUpdating, setIsUpdating] = useState(false);
const handleVisibilityToggle = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (isUpdating) return;
setIsUpdating(true);
setTimeout(() => {
updateCustomItem(sectionId, item.id, { visible: !item.visible });
setIsUpdating(false);
}, 10);
},
[item, updateCustomItem, isUpdating, sectionId]
);
return (
<Reorder.Item
id={item.id}
value={item}
dragListener={false}
dragControls={dragControls}
className={cn(
"rounded-lg border overflow-hidden flex group",
theme === "dark"
? "bg-neutral-900/30 border-neutral-800"
: "bg-white border-gray-100"
)}
>
<div
onPointerDown={(event) => {
if (expandedId === item.id) return;
dragControls.start(event);
}}
className={cn(
"w-12 flex items-center justify-center border-r shrink-0 touch-none",
theme === "dark" ? "border-neutral-800" : "border-gray-100",
expandedId === item.id
? "cursor-not-allowed"
: "cursor-grab hover:bg-gray-50 dark:hover:bg-neutral-800/50"
)}
>
<GripVertical
className={cn(
"w-4 h-4",
theme === "dark" ? "text-neutral-400" : "text-gray-400",
expandedId === item.id && "opacity-50"
)}
/>
</div>
<div className="flex-1 min-w-0">
<div
className={cn(
"px-4 py-4 flex items-center justify-between",
expandedId === item.id &&
(theme === "dark" ? "bg-neutral-800/50" : "bg-gray-50")
)}
onClick={() => setExpandedId(expandedId === item.id ? null : item.id)}
>
<div className="flex-1 min-w-0">
<h3
className={cn(
"font-medium truncate text-gray-700",
"dark:text-neutral-200"
)}
>
{item.title || "未命名模块"}
</h3>
{item.subtitle && (
<p
className={cn(
"text-sm truncate",
theme === "dark" ? "text-neutral-400" : "text-gray-500"
)}
>
{item.subtitle}
</p>
)}
</div>
<div className="flex items-center gap-2 ml-4 shrink-0">
<Button
variant="ghost"
size="sm"
disabled={isUpdating}
onClick={handleVisibilityToggle}
>
{item.visible ? (
<Eye className="w-4 h-4 text-indigo-600" />
) : (
<EyeOff className="w-4 h-4" />
)}
</Button>
<DeleteConfirmDialog
title={item.title}
onDelete={() => {
removeCustomItem(sectionId, item.id);
setExpandedId(null);
}}
/>
<motion.div
initial={false}
animate={{
rotate: expandedId === item.id ? 180 : 0
}}
>
<ChevronDown
className={cn(
"w-5 h-5",
theme === "dark" ? "text-neutral-400" : "text-gray-500"
)}
/>
</motion.div>
</div>
</div>
<AnimatePresence>
{expandedId === item.id && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div
className={cn(
"px-4 pb-4 space-y-4",
theme === "dark" ? "border-neutral-800" : "border-gray-100"
)}
>
<div
className={cn(
"h-px w-full",
theme === "dark" ? "bg-neutral-800" : "bg-gray-100"
)}
/>
<CustomItemEditor
item={item}
onSave={(updatedItem) => {
updateCustomItem(sectionId, item.id, updatedItem);
}}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</Reorder.Item>
);
};
export default CustomItem;
@@ -0,0 +1,54 @@
import { memo } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Reorder } from "framer-motion";
import { PlusCircle } from "lucide-react";
import CustomItem from "./CustomItem";
import { useResumeStore } from "@/store/useResumeStore";
import { CustomItem as CustomItemType } from "@/types/resume";
const CustomPanel = memo(({ sectionId }: { sectionId: string }) => {
const { customData, addCustomItem, updateCustomData } = useResumeStore();
const items = customData[sectionId] || [];
const handleCreateItem = () => {
addCustomItem(sectionId);
};
return (
<div
className={cn(
"space-y-4 px-4 py-4 rounded-lg",
"dark:bg-neutral-900/30 bg-white"
)}
>
<Reorder.Group
axis="y"
values={items}
onReorder={(newOrder) => {
updateCustomData(sectionId, newOrder);
}}
className="space-y-3"
>
{items.map((item: CustomItemType) => (
<CustomItem key={item.id} item={item} sectionId={sectionId} />
))}
<Button
onClick={handleCreateItem}
className={cn(
"w-full",
"dark:bg-indigo-600 hover:bg-indigo-700 text-white",
"bg-indigo-600 hover:bg-indigo-700 text-white"
)}
>
<PlusCircle className="w-4 h-4 mr-2" />
</Button>
</Reorder.Group>
</div>
);
});
CustomPanel.displayName = "CustomPanel";
export default CustomPanel;
@@ -16,7 +16,8 @@ const ExperiencePanel = () => {
company: "某科技有限公司",
position: "高级前端工程师",
date: "2020-至今",
details: "负责公司核心产品..."
details: "负责公司核心产品...",
visible: true
};
updateExperience(newProject);
};
@@ -0,0 +1,91 @@
"use client";
import { motion } from "framer-motion";
import { GlobalSettings } from "@/types/resume";
import { SectionTitle } from "./SectionTitle";
import { CustomItem } from "@/types/resume";
interface CustomSectionProps {
sectionId: string;
title: string;
items: CustomItem[];
globalSettings: GlobalSettings | undefined;
themeColor: string;
}
export function CustomSection({
title,
items,
globalSettings,
themeColor
}: CustomSectionProps) {
const visibleItems = items.filter((item) => {
return item.visible && item.description;
});
return (
<motion.div
layout
style={{
marginTop: `${globalSettings?.sectionSpacing || 24}px`
}}
>
<SectionTitle
title={title}
themeColor={themeColor}
globalSettings={globalSettings}
/>
{visibleItems.map((item) => (
<div
key={item.id}
style={{
marginTop: `${globalSettings?.paragraphSpacing}px`
}}
>
<div className="flex justify-between items-start">
<div className="space-y-1">
<div>
<h4
className="font-medium text-gray-800"
style={{
fontSize: `${globalSettings?.subheaderSize || 16}px`
}}
>
{item.title}
</h4>
<motion.div
layout
className={"text-gray-600"}
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{item.subtitle}
</motion.div>
</div>
</div>
{item.dateRange && (
<span
className="text-gray-600 shrink-0 ml-4"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`
}}
>
{item.dateRange}
</span>
)}
</div>
{item.description && (
<div
className="text-gray-600 mt-1"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
lineHeight: globalSettings?.lineHeight || 1.6
}}
dangerouslySetInnerHTML={{ __html: item.description }}
/>
)}
</div>
))}
</motion.div>
);
}
@@ -14,9 +14,7 @@ export function EducationSection({
globalSettings,
themeColor
}: EducationSectionProps) {
// 只显示visible为true的教育经历
const visibleEducation = education.filter((edu) => edu.visible);
return (
<motion.div
layout
@@ -11,6 +11,7 @@ import { SectionTitle } from "./SectionTitle";
import { ProjectItem } from "./ProjectItem";
import { ExperienceSection } from "./ExperienceSection";
import { EducationSection } from "./EducationSection";
import { CustomSection } from "./CustomSection";
const getFontFamilyClass = (fontFamily: string) => {
switch (fontFamily) {
@@ -67,7 +68,8 @@ export function PreviewPanel() {
globalSettings,
projects,
draggingProjectId,
colorTheme
colorTheme,
customData
} = useResumeStore();
const previewRef = React.useRef<HTMLDivElement>(null);
@@ -196,6 +198,19 @@ export function PreviewPanel() {
}, [contentHeight]);
const renderSection = (sectionId: string) => {
if (sectionId.startsWith("custom")) {
const sectionConfig = menuSections.find((s) => s.id === sectionId);
return (
<CustomSection
sectionId={sectionId}
title={sectionConfig?.title || "自定义模块"}
items={customData[sectionId] || []}
globalSettings={globalSettings}
themeColor={currentThemeColor}
/>
);
}
switch (sectionId) {
case "education":
return (
@@ -3,7 +3,7 @@ import { GlobalSettings } from "@/types/resume";
interface SectionTitleProps {
title: string;
themeColor: string;
globalSettings: GlobalSettings;
globalSettings?: GlobalSettings;
}
export function SectionTitle({
+97 -5
View File
@@ -6,9 +6,15 @@ import {
Experience,
GlobalSettings,
DEFAULT_CONFIG,
Project
Project,
CustomItem
} from "../types/resume";
interface CustomSection {
id: string;
items: CustomItem[];
}
interface ResumeStore {
// 基础数据
basic: BasicInfo;
@@ -23,13 +29,13 @@ interface ResumeStore {
enabled: boolean;
order: number;
}[];
customData: Record<string, CustomItem[]>;
// 主题设置
theme: "light" | "dark";
activeSection: string;
colorTheme: string; // 当前使用的主题色 ID
// 当前使用的主题色 ID
colorTheme: string;
setColorTheme: (colorTheme: string) => void;
// Actions
@@ -43,6 +49,19 @@ interface ResumeStore {
reorderSections: (newOrder: typeof initialState.menuSections) => void;
toggleSectionVisibility: (sectionId: string) => void;
setActiveSection: (sectionId: string) => void;
updateMenuSections: (sections: typeof initialState.menuSections) => void;
addCustomData: (sectionId: string) => void;
updateCustomData: (sectionId: string, items: CustomItem[]) => void;
removeCustomData: (sectionId: string) => void;
addCustomItem: (sectionId: string) => void;
updateCustomItem: (
sectionId: string,
itemId: string,
updates: Partial<CustomItem>
) => void;
removeCustomItem: (sectionId: string, itemId: string) => void;
toggleTheme: () => void;
// 全局设置
globalSettings: GlobalSettings;
@@ -107,6 +126,7 @@ const initialState = {
{ id: "skills", title: "技能特长", icon: "⚡", enabled: true, order: 3 },
{ id: "projects", title: "项目经历", icon: "🚀", enabled: true, order: 4 }
],
customData: {},
theme: "light" as const,
colorTheme: "#2563eb",
@@ -201,6 +221,8 @@ export const useResumeStore = create<ResumeStore>()(
setActiveSection: (sectionId) => set({ activeSection: sectionId }),
updateMenuSections: (sections) => set({ menuSections: sections }),
updateProjects: (project) =>
set((state) => {
const newProjects = state.projects.some((p) => p.id === project.id)
@@ -253,7 +275,77 @@ export const useResumeStore = create<ResumeStore>()(
return {
globalSettings: newSettings
};
})
}),
addCustomData: (sectionId) =>
set((state) => ({
customData: {
...state.customData,
[sectionId]: [
{
id: crypto.randomUUID(),
title: "",
subtitle: "",
dateRange: "",
description: "",
visible: true
}
]
}
})),
updateCustomData: (sectionId, items) =>
set((state) => ({
customData: {
...state.customData,
[sectionId]: items
}
})),
removeCustomData: (sectionId) =>
set((state) => {
const { [sectionId]: _, ...rest } = state.customData;
return { customData: rest };
}),
addCustomItem: (sectionId) =>
set((state) => ({
customData: {
...state.customData,
[sectionId]: [
...(state.customData[sectionId] || []),
{
id: crypto.randomUUID(),
title: "",
subtitle: "",
dateRange: "",
description: "",
visible: true
}
]
}
})),
updateCustomItem: (sectionId, itemId, updates) => {
console.log(sectionId, "sectionId");
set((state) => ({
customData: {
...state.customData,
[sectionId]: state.customData[sectionId].map((item) =>
item.id === itemId ? { ...item, ...updates } : item
)
}
}));
},
removeCustomItem: (sectionId, itemId) =>
set((state) => ({
customData: {
...state.customData,
[sectionId]: state.customData[sectionId].filter(
(item) => item.id !== itemId
)
}
}))
}),
{
+9
View File
@@ -140,6 +140,15 @@ export interface ResumeTheme {
color: string;
}
export interface CustomItem {
id: string;
title: string;
subtitle: string;
dateRange: string;
description: string;
visible: boolean;
}
export const THEME_COLORS = [
"#2563eb",
"#059669",