From 527a354acf0a2bde4eb8716ffac77e4a3f3157de Mon Sep 17 00:00:00 2001 From: JOYCEQL <1449239013@qq.com> Date: Mon, 9 Dec 2024 23:57:43 +0800 Subject: [PATCH] feat: offline storage config --- apps/fronted/next.config.mjs | 2 - apps/fronted/src/app/dashboard/layout.tsx | 115 ++++++++++++ apps/fronted/src/app/dashboard/page.tsx | 6 + .../src/app/dashboard/resumes/page.tsx | 130 +++++++++++++ .../src/app/dashboard/settings/page.tsx | 118 ++++++++++++ apps/fronted/src/app/workbench/[id]/page.tsx | 7 +- apps/fronted/src/app/workbench/page.tsx | 164 ---------------- .../src/components/editor/EditorHeader.tsx | 2 +- .../src/components/editor/SidePanel.tsx | 1 - .../src/components/shared/EditButton.tsx | 2 +- apps/fronted/src/components/shared/Logo.tsx | 68 +++++++ apps/fronted/src/components/ui/sidebar.tsx | 80 +++----- apps/fronted/src/store/useResumeStore.ts | 175 ++++++++++++++---- apps/fronted/src/types/global.d.ts | 14 ++ apps/fronted/src/utils/fileSystem.ts | 118 ++++++++++++ 15 files changed, 730 insertions(+), 272 deletions(-) create mode 100644 apps/fronted/src/app/dashboard/layout.tsx create mode 100644 apps/fronted/src/app/dashboard/page.tsx create mode 100644 apps/fronted/src/app/dashboard/resumes/page.tsx create mode 100644 apps/fronted/src/app/dashboard/settings/page.tsx delete mode 100644 apps/fronted/src/app/workbench/page.tsx create mode 100644 apps/fronted/src/components/shared/Logo.tsx create mode 100644 apps/fronted/src/types/global.d.ts create mode 100644 apps/fronted/src/utils/fileSystem.ts diff --git a/apps/fronted/next.config.mjs b/apps/fronted/next.config.mjs index 1b94064..25ae6da 100644 --- a/apps/fronted/next.config.mjs +++ b/apps/fronted/next.config.mjs @@ -1,6 +1,4 @@ /** @type {import('next').NextConfig} */ -import { fileURLToPath } from "url"; -import { dirname } from "path"; const nextConfig = { typescript: { diff --git a/apps/fronted/src/app/dashboard/layout.tsx b/apps/fronted/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..65aca4a --- /dev/null +++ b/apps/fronted/src/app/dashboard/layout.tsx @@ -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 ( +
+ + + +
+ +
+
+ + + + + {sidebarItems.map((item) => ( + + + + + +
{ + router.push(item.url); + }} + > + + {open && {item.title}} +
+
+
+
+ {!open && ( + + {item.title} + + )} +
+
+ ))} +
+
+
+
+ +
+
+
+ +
+
{children}
+
+
+
+ ); +}; + +export default DashboardLayout; diff --git a/apps/fronted/src/app/dashboard/page.tsx b/apps/fronted/src/app/dashboard/page.tsx new file mode 100644 index 0000000..f3469d9 --- /dev/null +++ b/apps/fronted/src/app/dashboard/page.tsx @@ -0,0 +1,6 @@ +"use client"; +import { redirect } from "next/navigation"; + +export default function Dashboard() { + redirect("/dashboard/resumes"); +} diff --git a/apps/fronted/src/app/dashboard/resumes/page.tsx b/apps/fronted/src/app/dashboard/resumes/page.tsx new file mode 100644 index 0000000..47e7e36 --- /dev/null +++ b/apps/fronted/src/app/dashboard/resumes/page.tsx @@ -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 ; +}; + +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 ( + <> +
+

我的简历

+ +
+
+
+ + +
+ +
+ 创建新简历 + 从头开始创建一份新的简历 +
+
+ + {Object.entries(resumes).map(([id, resume]) => ( + + + {resume.title} + + + + 创建于 {new Date(resume.createdAt).toLocaleDateString()} + + + + + + + + ))} +
+
+ + ); +}; + +export default ResumesList; diff --git a/apps/fronted/src/app/dashboard/settings/page.tsx b/apps/fronted/src/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..74f2b73 --- /dev/null +++ b/apps/fronted/src/app/dashboard/settings/page.tsx @@ -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(null); + const [folderPath, setFolderPath] = useState(""); + + 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 ( +
+
+

系统设置

+
+
+ + + + + 文件同步配置 + + + 请选择一个文件夹用于存储简历数据。所有的简历将会以 JSON + 格式保存在这个文件夹中。 + + + +
+
+
+

+ {folderPath + ? `当前同步文件夹: ${folderPath}` + : "未配置同步文件夹"} +

+
+ +
+ {!folderPath && ( +

+ 请先选择一个文件夹用于同步简历数据 +

+ )} +
+
+
+
+
+ ); +}; + +export default SettingsPage; diff --git a/apps/fronted/src/app/workbench/[id]/page.tsx b/apps/fronted/src/app/workbench/[id]/page.tsx index 7f6c08b..138987d 100644 --- a/apps/fronted/src/app/workbench/[id]/page.tsx +++ b/apps/fronted/src/app/workbench/[id]/page.tsx @@ -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; diff --git a/apps/fronted/src/app/workbench/page.tsx b/apps/fronted/src/app/workbench/page.tsx deleted file mode 100644 index 03c6be8..0000000 --- a/apps/fronted/src/app/workbench/page.tsx +++ /dev/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 ( -
- - - -
-

Magic Resume

-
-
- - - 导航 - - - {sidebarItems.map((item) => ( - - - - - {item.title} - - - - ))} - - - - -
-
-
-
-
- -

简历

-
-
- - -
-
- -
-
- - createResume(null)} - > -
- -
- 创建新简历 - 从头开始创建 -
-
- - {Object.entries(resumes).map(([id, resume]) => ( - - - {resume.title} - - -

- 最后修改: {resume.updatedAt} -

-
- - - - -
- ))} -
-
-
-
-
-
- ); -}; - -export default ResumeWorkbench; diff --git a/apps/fronted/src/components/editor/EditorHeader.tsx b/apps/fronted/src/components/editor/EditorHeader.tsx index 0fc5be0..4bc570d 100644 --- a/apps/fronted/src/components/editor/EditorHeader.tsx +++ b/apps/fronted/src/components/editor/EditorHeader.tsx @@ -33,7 +33,7 @@ export function EditorHeader({ isMobile }: EditorHeaderProps) { whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} onClick={() => { - router.push("/"); + router.push("/dashboard"); }} > Magic Resume diff --git a/apps/fronted/src/components/editor/SidePanel.tsx b/apps/fronted/src/components/editor/SidePanel.tsx index 2469290..062cef1 100644 --- a/apps/fronted/src/components/editor/SidePanel.tsx +++ b/apps/fronted/src/components/editor/SidePanel.tsx @@ -570,7 +570,6 @@ export function SidePanel() { checked={globalSettings.useIconMode} onCheckedChange={(checked) => updateGlobalSettings({ - ...globalSettings, useIconMode: checked, }) } diff --git a/apps/fronted/src/components/shared/EditButton.tsx b/apps/fronted/src/components/shared/EditButton.tsx index c2e0ab8..2181af8 100644 --- a/apps/fronted/src/components/shared/EditButton.tsx +++ b/apps/fronted/src/components/shared/EditButton.tsx @@ -8,7 +8,7 @@ const EditButton = ( RefAttributes ) => { return ( - + ); diff --git a/apps/fronted/src/components/shared/Logo.tsx b/apps/fronted/src/components/shared/Logo.tsx new file mode 100644 index 0000000..f8899b4 --- /dev/null +++ b/apps/fronted/src/components/shared/Logo.tsx @@ -0,0 +1,68 @@ +import React from "react"; + +interface LogoProps { + size?: number; + className?: string; +} + +const Logo: React.FC = ({ size = 100, className = "" }) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default Logo; diff --git a/apps/fronted/src/components/ui/sidebar.tsx b/apps/fronted/src/components/ui/sidebar.tsx index b2cd66d..23ab98e 100644 --- a/apps/fronted/src/components/ui/sidebar.tsx +++ b/apps/fronted/src/components/ui/sidebar.tsx @@ -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<
  • )); 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; - } & VariantProps + } >( ( { 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 ( 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 ( - - {button} - + > + {children} + ); } ); diff --git a/apps/fronted/src/store/useResumeStore.ts b/apps/fronted/src/store/useResumeStore.ts index 52a02fa..43066a3 100644 --- a/apps/fronted/src/store/useResumeStore.ts +++ b/apps/fronted/src/store/useResumeStore.ts @@ -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) => void; setActiveResume: (resumeId: string) => void; + updateResumeFromFile: (resume: ResumeData) => void; updateBasicInfo: (data: Partial) => void; updateEducation: (data: Education) => void; @@ -201,20 +203,79 @@ const initialResumeState: Omit = { globalSettings: initialGlobalSettings, }; -export const useResumeStore = create()( - 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( (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()( ? 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()( 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()( get().updateResume(activeResumeId, { customData: updatedCustomData }); } }, + updateGlobalSettings: (settings: Partial) => { - 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) { diff --git a/apps/fronted/src/types/global.d.ts b/apps/fronted/src/types/global.d.ts new file mode 100644 index 0000000..810a626 --- /dev/null +++ b/apps/fronted/src/types/global.d.ts @@ -0,0 +1,14 @@ +declare global { + interface Window { + showDirectoryPicker( + options?: FilePickerOptions + ): Promise; + } +} + +interface FilePickerOptions { + multiple?: boolean; + mode?: "read" | "readwrite"; +} + +export {}; diff --git a/apps/fronted/src/utils/fileSystem.ts b/apps/fronted/src/utils/fileSystem.ts new file mode 100644 index 0000000..02d6878 --- /dev/null +++ b/apps/fronted/src/utils/fileSystem.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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; +};