mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-07-03 14:07:11 +02:00
feat: offline storage config
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname } from "path";
|
||||
|
||||
const nextConfig = {
|
||||
typescript: {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
showDirectoryPicker(
|
||||
options?: FilePickerOptions
|
||||
): Promise<FileSystemDirectoryHandle>;
|
||||
}
|
||||
}
|
||||
|
||||
interface FilePickerOptions {
|
||||
multiple?: boolean;
|
||||
mode?: "read" | "readwrite";
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user