feat: offline storage config

This commit is contained in:
JOYCEQL
2024-12-09 23:57:43 +08:00
committed by qingchen
parent 24ff44d30d
commit 527a354acf
15 changed files with 730 additions and 272 deletions
-2
View File
@@ -1,6 +1,4 @@
/** @type {import('next').NextConfig} */
import { fileURLToPath } from "url";
import { dirname } from "path";
const nextConfig = {
typescript: {
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { FileText, Settings, SettingsIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { useEffect, useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import Logo from "@/components/shared/Logo";
const sidebarItems = [
{
title: "简历",
url: "/dashboard/resumes",
icon: FileText,
},
{
title: "设置",
url: "/dashboard/settings",
icon: Settings,
},
];
const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const pathname = usePathname();
const [open, setOpen] = useState(true);
const [collapsible, setCollapsible] = useState<"offcanvas" | "icon" | "none">(
"icon"
);
useEffect(() => {
if (pathname.includes("/workbench")) {
setOpen(false);
return;
}
}, [pathname]);
return (
<div className="flex h-screen bg-background">
<SidebarProvider open={open} onOpenChange={setOpen}>
<Sidebar collapsible={collapsible}>
<SidebarHeader>
<div className="w-full justify-center flex">
<Logo size={40} />
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{sidebarItems.map((item) => (
<TooltipProvider delayDuration={300} key={item.title}>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuItem key={item.title} title={item.title}>
<SidebarMenuButton
asChild
isActive={item.url === pathname}
>
<div
className="flex items-center gap-2 p-[8px]"
onClick={() => {
router.push(item.url);
}}
>
<item.icon className="w-4 h-4 shrink-0" />
{open && <span>{item.title}</span>}
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</TooltipTrigger>
{!open && (
<TooltipContent side="right">
{item.title}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter></SidebarFooter>
</Sidebar>
<main className="flex-1 flex flex-col">
<div className="p-2">
<SidebarTrigger />
</div>
<div className="flex-1">{children}</div>
</main>
</SidebarProvider>
</div>
);
};
export default DashboardLayout;
+6
View File
@@ -0,0 +1,6 @@
"use client";
import { redirect } from "next/navigation";
export default function Dashboard() {
redirect("/dashboard/resumes");
}
@@ -0,0 +1,130 @@
"use client";
import React, { useEffect } from "react";
import { Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { getFileHandle, verifyPermission } from "@/utils/fileSystem";
import { useResumeStore } from "@/store/useResumeStore";
const ResumesList = () => {
return <ResumeWorkbench />;
};
const ResumeWorkbench = () => {
const {
resumes,
createResume,
deleteResume,
setActiveResume,
updateResume,
updateResumeFromFile,
} = useResumeStore();
const router = useRouter();
useEffect(() => {
const syncResumesFromFiles = async () => {
try {
const handle = await getFileHandle("syncDirectory");
if (!handle) return;
const hasPermission = await verifyPermission(handle);
if (!hasPermission) return;
const dirHandle = handle as FileSystemDirectoryHandle;
for await (const entry of dirHandle.values()) {
if (entry.kind === "file" && entry.name.endsWith(".json")) {
try {
const file = await entry.getFile();
const content = await file.text();
const resumeData = JSON.parse(content);
updateResumeFromFile(resumeData);
} catch (error) {
console.error("Error reading resume file:", error);
}
}
}
} catch (error) {
console.error("Error syncing resumes from files:", error);
}
};
if (Object.keys(resumes).length === 0) {
syncResumesFromFiles();
}
}, [resumes, updateResume]);
const handleCreateResume = () => {
const newId = createResume(null);
setActiveResume(newId);
};
return (
<>
<div className="h-[60px] border-b px-6 flex items-center justify-between">
<h1 className="text-xl font-semibold"></h1>
<Button onClick={handleCreateResume}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="flex-1 p-6 overflow-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="border-dashed hover:border-primary/50 cursor-pointer">
<CardContent
className="pt-6 text-center"
onClick={handleCreateResume}
>
<div className="mb-4 w-12 h-12 rounded-full bg-primary/10 mx-auto flex items-center justify-center">
<Plus className="text-primary" size={24} />
</div>
<CardTitle className="text-lg mb-2"></CardTitle>
<CardDescription></CardDescription>
</CardContent>
</Card>
{Object.entries(resumes).map(([id, resume]) => (
<Card key={id}>
<CardHeader>
<CardTitle>{resume.title}</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>
{new Date(resume.createdAt).toLocaleDateString()}
</CardDescription>
</CardContent>
<CardFooter className="gap-4">
<Button
variant="outline"
className="flex-1"
onClick={() => {
setActiveResume(id);
router.push(`/workbench/${id}`);
}}
>
</Button>
<Button
variant="outline"
className="flex-1"
onClick={() => deleteResume(resume)}
>
</Button>
</CardFooter>
</Card>
))}
</div>
</div>
</>
);
};
export default ResumesList;
@@ -0,0 +1,118 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Folder } from "lucide-react";
import { useState, useEffect } from "react";
import { useConfigStore } from "@/store/useConfigStore";
import {
getFileHandle,
getConfig,
storeFileHandle,
storeConfig,
verifyPermission,
} from "@/utils/fileSystem";
const SettingsPage = () => {
const [directoryHandle, setDirectoryHandle] =
useState<FileSystemDirectoryHandle | null>(null);
const [folderPath, setFolderPath] = useState<string>("");
useEffect(() => {
const loadSavedConfig = async () => {
try {
const handle = await getFileHandle("syncDirectory");
const path = await getConfig("syncDirectoryPath");
if (handle && path) {
const hasPermission = await verifyPermission(handle);
if (hasPermission) {
setDirectoryHandle(handle as FileSystemDirectoryHandle);
setFolderPath(path);
}
}
} catch (error) {
console.error("Error loading saved config:", error);
}
};
loadSavedConfig();
}, []);
const handleSelectDirectory = async () => {
try {
if (!("showDirectoryPicker" in window)) {
alert(
"Your browser does not support directory selection. Please use a modern browser."
);
return;
}
const handle = await window.showDirectoryPicker({ mode: "readwrite" });
const hasPermission = await verifyPermission(handle);
if (hasPermission) {
setDirectoryHandle(handle);
// 获取文件夹路径
const path = handle.name;
setFolderPath(path);
// 存储文件句柄和路径
await storeFileHandle("syncDirectory", handle);
await storeConfig("syncDirectoryPath", path);
}
} catch (error) {
console.error("Error selecting directory:", error);
}
};
return (
<div className="flex-1 overflow-auto">
<div className="h-[60px] border-b px-6 flex items-center">
<h1 className="text-xl font-semibold"></h1>
</div>
<div className="p-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Folder className="h-5 w-5" />
</CardTitle>
<CardDescription>
JSON
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="flex-1">
<p className="text-sm text-muted-foreground">
{folderPath
? `当前同步文件夹: ${folderPath}`
: "未配置同步文件夹"}
</p>
</div>
<Button
variant={folderPath ? "outline" : "default"}
onClick={handleSelectDirectory}
>
{folderPath ? "更改文件夹" : "选择文件夹"}
</Button>
</div>
{!folderPath && (
<p className="text-sm text-yellow-500">
</p>
)}
</div>
</CardContent>
</Card>
</div>
</div>
);
};
export default SettingsPage;
+3 -4
View File
@@ -10,7 +10,7 @@ import { PreviewPanel } from "@/components/preview/PreviewPanel";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@@ -18,7 +18,7 @@ import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
TooltipTrigger,
} from "@/components/ui/tooltip";
const LAYOUT_CONFIG = {
@@ -27,10 +27,9 @@ const LAYOUT_CONFIG = {
EDIT_FOCUSED_WITH_SIDE: [0, 100, 0],
PREVIEW_FOCUSED_WITH_SIDE: [0, 0, 100],
EDIT_FOCUSED_NO_SIDE: [0, 100, 0],
PREVIEW_FOCUSED_NO_SIDE: [0, 0, 100]
PREVIEW_FOCUSED_NO_SIDE: [0, 0, 100],
};
// 创建自定义拖拽手柄组件
const DragHandle = ({ show = true }) => {
if (!show) return null;
-164
View File
@@ -1,164 +0,0 @@
"use client";
import React, { useState } from "react";
import { useResumeStore } from "@/store/useResumeStore";
import { useRouter, usePathname } from "next/navigation";
import { LayoutGrid, List, FileText, Plus, Settings } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from "@/components/ui/card";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
const sidebarItems = [
{
title: "简历",
url: "/workbench",
icon: FileText,
},
{
title: "设置",
url: "#",
icon: Settings,
},
];
const ResumeWorkbench = () => {
const { resumes, createResume, deleteResume } = useResumeStore();
const router = useRouter();
const pathname = usePathname();
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const { setActiveResume } = useResumeStore();
return (
<div className="flex h-screen bg-background">
<SidebarProvider>
<Sidebar>
<SidebarHeader>
<div className="p-4">
<h2 className="text-xl font-semibold">Magic Resume</h2>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel></SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{sidebarItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={item.url === pathname}
>
<a href={item.url} className="flex items-center gap-2">
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<main className="flex-1 flex ">
<div className="flex-1">
<header className="border-b p-4 flex justify-between items-center">
<div className="flex">
<SidebarTrigger />
<h2 className="text-xl font-semibold"></h2>
</div>
<div className="flex items-center gap-2">
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="icon"
onClick={() => setViewMode("grid")}
>
<LayoutGrid size={20} />
</Button>
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
size="icon"
onClick={() => setViewMode("list")}
>
<List size={20} />
</Button>
</div>
</header>
<div className="flex-1 p-6 overflow-auto">
<div
className={`grid ${viewMode === "grid" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3" : "grid-cols-1"} gap-6`}
>
<Card className="border-dashed hover:border-primary/50 cursor-pointer">
<CardContent
className="pt-6 text-center"
onClick={() => createResume(null)}
>
<div className="mb-4 w-12 h-12 rounded-full bg-primary/10 mx-auto flex items-center justify-center">
<Plus className="text-primary" size={24} />
</div>
<CardTitle className="text-lg mb-2"></CardTitle>
<CardDescription></CardDescription>
</CardContent>
</Card>
{Object.entries(resumes).map(([id, resume]) => (
<Card key={id} className="group">
<CardHeader>
<CardTitle>{resume.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
: {resume.updatedAt}
</p>
</CardContent>
<CardFooter className="opacity-0 group-hover:opacity-100 transition-opacity gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => {
setActiveResume(id);
router.push(`/workbench/${id}`);
}}
>
</Button>
<Button
variant="destructive"
className="flex-1"
onClick={() => deleteResume(id)}
>
</Button>
</CardFooter>
</Card>
))}
</div>
</div>
</div>
</main>
</SidebarProvider>
</div>
);
};
export default ResumeWorkbench;
@@ -33,7 +33,7 @@ export function EditorHeader({ isMobile }: EditorHeaderProps) {
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => {
router.push("/");
router.push("/dashboard");
}}
>
<span className="text-lg font-semibold">Magic Resume</span>
@@ -570,7 +570,6 @@ export function SidePanel() {
checked={globalSettings.useIconMode}
onCheckedChange={(checked) =>
updateGlobalSettings({
...globalSettings,
useIconMode: checked,
})
}
@@ -8,7 +8,7 @@ const EditButton = (
RefAttributes<HTMLButtonElement>
) => {
return (
<Link href={"/workbench"}>
<Link href={"/dashboard"}>
<Button {...props}>{props.children}</Button>
</Link>
);
@@ -0,0 +1,68 @@
import React from "react";
interface LogoProps {
size?: number;
className?: string;
}
const Logo: React.FC<LogoProps> = ({ size = 100, className = "" }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-label="Magic Resume Logo"
>
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#8B5CF6" />
<stop offset="100%" stopColor="#4F46E5" />
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<circle cx="50" cy="50" r="50" fill="url(#gradient)" />
<path
d="M35 25H65C67.7614 25 70 27.2386 70 30V70C70 72.7614 67.7614 75 65 75H35C32.2386 75 30 72.7614 30 70V30C30 27.2386 32.2386 25 35 25Z"
fill="white"
/>
<path
d="M38 35H62M38 45H62M38 55H52"
stroke="#E2E8F0"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M30 40C40 35 60 65 70 60"
stroke="#8B5CF6"
strokeWidth="3"
strokeLinecap="round"
filter="url(#glow)"
>
<animate
attributeName="d"
values="M30 40C40 35 60 65 70 60; M30 60C40 65 60 35 70 40; M30 40C40 35 60 65 70 60"
dur="4s"
repeatCount="indefinite"
/>
</path>
<path
d="M65.5 34.5L61.75 38.25L69.25 45.75L73 42L65.5 34.5ZM58 42L34.5 65.5V73H42L65.5 49.5L58 42ZM43.7 67.5L37.5 61.3L55.9 42.9L62.1 49.1L43.7 67.5Z"
fill="#4F46E5"
/>
</svg>
);
};
export default Logo;
+21 -59
View File
@@ -23,7 +23,7 @@ const SIDEBAR_COOKIE_NAME = "sidebar:state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_WIDTH_ICON = "4rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContext = {
@@ -505,88 +505,50 @@ const SidebarMenuItem = React.forwardRef<
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
className={cn(
"relative flex w-full min-w-0 items-center gap-2",
"group-data-[collapsible=icon]:justify-center",
className
)}
{...props}
/>
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
}
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
children,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const Comp = asChild ? Slot : "a";
const button = (
return (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
className={cn(
"flex h-9 w-full min-w-0 cursor-pointer select-none items-center rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:w-10 group-data-[collapsible=icon]:h-10",
isActive && "bg-sidebar-accent text-sidebar-accent-foreground",
className
)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
>
{children}
</Comp>
);
}
);
+135 -40
View File
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { DEFAULT_FIELD_ORDER } from "@/config";
import { getFileHandle, getConfig, verifyPermission } from "@/utils/fileSystem";
import {
BasicInfo,
Education,
@@ -18,11 +19,12 @@ interface ResumeStore {
activeResumeId: string | null;
activeResume: ResumeData | null;
createResume: (templateId: string | null) => void;
deleteResume: (resumeId: string) => void;
createResume: (templateId: string | null) => string;
deleteResume: (resume: ResumeData) => void;
duplicateResume: (resumeId: string) => void;
updateResume: (resumeId: string, data: Partial<ResumeData>) => void;
setActiveResume: (resumeId: string) => void;
updateResumeFromFile: (resume: ResumeData) => void;
updateBasicInfo: (data: Partial<BasicInfo>) => void;
updateEducation: (data: Education) => void;
@@ -201,20 +203,79 @@ const initialResumeState: Omit<ResumeData, "id" | "createdAt" | "updatedAt"> = {
globalSettings: initialGlobalSettings,
};
export const useResumeStore = create<ResumeStore>()(
persist(
// 同步简历到文件系统
const syncResumeToFile = async (resumeData: ResumeData) => {
try {
const handle = await getFileHandle("syncDirectory");
if (!handle) {
console.warn("No directory handle found");
return;
}
const hasPermission = await verifyPermission(handle);
if (!hasPermission) {
console.warn("No permission to write to directory");
return;
}
const dirHandle = handle as FileSystemDirectoryHandle;
const fileName = `${resumeData.title}.json`;
const fileHandle = await dirHandle.getFileHandle(fileName, {
create: true,
});
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(resumeData, null, 2));
await writable.close();
} catch (error) {
console.error("Error syncing resume to file:", error);
}
};
export const useResumeStore = create(
persist<ResumeStore>(
(set, get) => ({
resumes: {},
activeResumeId: null,
activeResume: null,
updateResume: (resumeId, data) =>
createResume: (templateId = null) => {
const newId = crypto.randomUUID();
const newResume: ResumeData = {
...initialResumeState,
id: newId,
templateId,
title: "新简历" + newId.slice(0, 6),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
set((state) => ({
resumes: {
...state.resumes,
[newId]: newResume,
},
activeResumeId: newId,
activeResume: newResume,
}));
syncResumeToFile(newResume);
return newId;
},
updateResume: (resumeId, data) => {
set((state) => {
const resume = state.resumes[resumeId];
if (!resume) return state;
const updatedResume = {
...state.resumes[resumeId],
...resume,
...data,
updatedAt: new Date().toISOString(),
};
syncResumeToFile(updatedResume);
return {
resumes: {
...state.resumes,
@@ -225,36 +286,46 @@ export const useResumeStore = create<ResumeStore>()(
? updatedResume
: state.activeResume,
};
}),
createResume: (templateId = null) => {
const newId = crypto.randomUUID();
const newResume: ResumeData = {
...initialResumeState,
id: newId,
templateId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
get().updateResume(newId, newResume);
get().setActiveResume(newId);
});
},
deleteResume: (resumeId) => {
const { resumes, activeResumeId } = get();
const { [resumeId]: _, ...remainingResumes } = resumes;
const newActiveResumeId =
activeResumeId === resumeId ? null : activeResumeId;
// 从文件更新,直接更新resumes
updateResumeFromFile: (resume) => {
set((state) => ({
resumes: {
...state.resumes,
[resume.id]: resume,
},
}));
},
set({ resumes: remainingResumes });
deleteResume: (resume) => {
const resumeId = resume.id;
set((state) => {
const { [resumeId]: _, activeResume, ...rest } = state.resumes;
return {
resumes: rest,
activeResumeId: null,
activeResume: null,
};
});
if (newActiveResumeId) {
get().updateResume(newActiveResumeId, {
...resumes[newActiveResumeId],
});
} else {
set({ activeResumeId: null, activeResume: null });
}
(async () => {
try {
const handle = await getFileHandle("syncDirectory");
if (!handle) return;
const hasPermission = await verifyPermission(handle);
if (!hasPermission) return;
const dirHandle = handle as FileSystemDirectoryHandle;
try {
await dirHandle.removeEntry(`${resume.title}.json`);
} catch (error) {}
} catch (error) {
console.error("Error deleting resume file:", error);
}
})();
},
duplicateResume: (resumeId) => {
@@ -277,13 +348,32 @@ export const useResumeStore = create<ResumeStore>()(
set({ activeResume: resume, activeResumeId: resumeId });
}
},
updateBasicInfo: (data) => {
const { activeResumeId, updateResume } = get();
if (activeResumeId) {
updateResume(activeResumeId, {
basic: { ...get().resumes[activeResumeId].basic, ...data },
});
}
set((state) => {
if (!state.activeResume || !state.activeResumeId) return state;
const updatedResume = {
...state.activeResume,
basic: {
...state.activeResume.basic,
...data,
},
updatedAt: new Date().toISOString(),
};
const newState = {
...state,
resumes: {
...state.resumes,
[state.activeResumeId]: updatedResume,
},
activeResume: updatedResume,
};
syncResumeToFile(updatedResume);
return newState;
});
},
updateEducation: (education) => {
@@ -535,14 +625,19 @@ export const useResumeStore = create<ResumeStore>()(
get().updateResume(activeResumeId, { customData: updatedCustomData });
}
},
updateGlobalSettings: (settings: Partial<GlobalSettings>) => {
const { activeResumeId, updateResume } = get();
const { activeResumeId, updateResume, activeResume } = get();
if (activeResumeId) {
updateResume(activeResumeId, {
globalSettings: settings,
globalSettings: {
...activeResume?.globalSettings,
...settings,
},
});
}
},
setColorTheme: (colorTheme) => {
const { activeResumeId, updateResume } = get();
if (activeResumeId) {
+14
View File
@@ -0,0 +1,14 @@
declare global {
interface Window {
showDirectoryPicker(
options?: FilePickerOptions
): Promise<FileSystemDirectoryHandle>;
}
}
interface FilePickerOptions {
multiple?: boolean;
mode?: "read" | "readwrite";
}
export {};
+118
View File
@@ -0,0 +1,118 @@
// 存储文件句柄和配置信息
const DB_NAME = "FileHandleDB";
const HANDLE_STORE = "handles";
const CONFIG_STORE = "config";
const DB_VERSION = 2;
let db: IDBDatabase | null = null;
const initDB = (): Promise<void> => {
return new Promise((resolve, reject) => {
if (db) {
resolve();
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(HANDLE_STORE)) {
db.createObjectStore(HANDLE_STORE);
}
if (!db.objectStoreNames.contains(CONFIG_STORE)) {
db.createObjectStore(CONFIG_STORE);
}
};
});
};
export const storeFileHandle = async (
key: string,
handle: FileSystemHandle
): Promise<void> => {
await initDB();
if (!db) throw new Error("Database not initialized");
return new Promise((resolve, reject) => {
const transaction = db.transaction(HANDLE_STORE, "readwrite");
const store = transaction.objectStore(HANDLE_STORE);
const request = store.put(handle, key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
};
export const getFileHandle = async (
key: string
): Promise<FileSystemHandle | null> => {
await initDB();
if (!db) throw new Error("Database not initialized");
return new Promise((resolve, reject) => {
const transaction = db.transaction(HANDLE_STORE, "readonly");
const store = transaction.objectStore(HANDLE_STORE);
const request = store.get(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
};
export const storeConfig = async (key: string, value: any): Promise<void> => {
await initDB();
if (!db) throw new Error("Database not initialized");
return new Promise((resolve, reject) => {
const transaction = db.transaction(CONFIG_STORE, "readwrite");
const store = transaction.objectStore(CONFIG_STORE);
const request = store.put(value, key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
};
export const getConfig = async (key: string): Promise<any> => {
await initDB();
if (!db) throw new Error("Database not initialized");
return new Promise((resolve, reject) => {
const transaction = db.transaction(CONFIG_STORE, "readonly");
const store = transaction.objectStore(CONFIG_STORE);
const request = store.get(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
};
export const verifyPermission = async (
handle: FileSystemHandle,
mode: FileSystemPermissionMode = "readwrite"
): Promise<boolean> => {
if (!handle) {
return false;
}
const options = { mode };
// 检查当前权限
if ((await handle.queryPermission(options)) === "granted") {
return true;
}
// 请求权限
if ((await handle.requestPermission(options)) === "granted") {
return true;
}
return false;
};