diff --git a/db-version.patch b/db-version.patch index 8eef6ef..d4ce9d7 100644 --- a/db-version.patch +++ b/db-version.patch @@ -93,6 +93,45 @@ index e2565bb..e483e67 100644 export interface EditorState { scene: Scene; +diff --git a/src/SharePage.tsx b/src/SharePage.tsx +new file mode 100644 +index 0000000..81bad97 +--- /dev/null ++++ b/src/SharePage.tsx +@@ -0,0 +1,33 @@ ++import React, { useCallback, useEffect } from 'react'; ++import { useLocation, useNavigate } from 'react-router-dom'; ++import { type DBSource, useLoadScene } from './SceneProvider'; ++import { openFile } from './file'; ++ ++export const SharePage: React.FC = () => { ++ const loadScene = useLoadScene(); ++ const navigate = useNavigate(); ++ const { hash } = useLocation(); ++ ++ const navigateToMainPage = useCallback(() => navigate('/', { replace: true }), [navigate]); ++ ++ const tryReadPlan = async (id: string) => { ++ const source: DBSource = { type: 'db', folder: '', name: '', id }; ++ const scene = await openFile(source); ++ ++ source.folder = localStorage.getItem('tmpFolder') ?? ''; ++ source.name = localStorage.getItem('tmpName') ?? ''; ++ ++ localStorage.removeItem('tmpFolder'); ++ localStorage.removeItem('tmpName'); ++ ++ loadScene(scene, source); ++ navigateToMainPage(); ++ }; ++ ++ useEffect(() => { ++ tryReadPlan(hash.substring(1)); ++ // eslint-disable-next-line react-hooks/exhaustive-deps ++ }, []); ++ ++ return <>Loading plan from DB, please wait . . .; ++}; diff --git a/src/SiteHeader.tsx b/src/SiteHeader.tsx index 524a0d3..8642bd4 100644 --- a/src/SiteHeader.tsx @@ -256,6 +295,520 @@ index 63a473b..d32cb53 100644 +diff --git a/src/file/FileDialogDB.tsx b/src/file/FileDialogDB.tsx +new file mode 100644 +index 0000000..05ea7ce +--- /dev/null ++++ b/src/file/FileDialogDB.tsx +@@ -0,0 +1,508 @@ ++import { ++ Button, ++ Checkbox, ++ createTableColumn, ++ DataGrid, ++ DataGridBody, ++ DataGridCell, ++ type DataGridCellFocusMode, ++ DataGridHeader, ++ DataGridHeaderCell, ++ type DataGridProps, ++ DataGridRow, ++ DialogActions, ++ DialogTrigger, ++ Field, ++ Input, ++ Label, ++ type TableColumnDefinition, ++ type TableColumnId, ++ type TableRowId, ++ Tooltip, ++} from '@fluentui/react-components'; ++import { ArrowDownloadRegular } from '@fluentui/react-icons'; ++import { DeleteIcon, EditIcon, InfoIcon, OpenFolderHorizontalIcon } from '@fluentui/react-icons-mdl2'; ++import { type MouseEvent, useEffect, useState } from 'react'; ++import { type HtmlPortalNode, InPortal } from 'react-reverse-portal'; ++import { useAsyncFn } from 'react-use'; ++import { openFile, saveFile } from '../file'; ++import { type FileSource, useLoadScene, useScene, useSetSource } from '../SceneProvider'; ++import { useCloseDialog } from '../useCloseDialog'; ++import { useIsDirty, useSetSavedState } from '../useIsDirty'; ++import { useConfirmDeleteFile, useConfirmUnsavedChanges } from './confirm'; ++import { type AuthUser, authUser, deletePlan, getPlans, movePlan, type Plan, renamePlan } from './db'; ++import { useSignUp } from './signUp'; ++ ++const getCellFocusMode = (columnId: TableColumnId): DataGridCellFocusMode => { ++ switch (columnId) { ++ case 'delete': ++ return 'none'; ++ default: ++ return 'cell'; ++ } ++}; ++ ++export interface SaveDBProps { ++ actions: HtmlPortalNode; ++} ++ ++interface DBUser { ++ saved: boolean; ++ userId?: string; ++ userName?: string; ++ userPin?: string; ++} ++ ++let dbUser: null | DBUser = null; ++const dbUserKey = 'dbUser'; ++ ++interface LoginSectionProps { ++ changeDBUser: (dbUser: DBUser | null) => void; ++} ++ ++const LoginSection: React.FC = ({ changeDBUser }) => { ++ const [signUpUser, renderModal] = useSignUp(); ++ ++ const lsUser: DBUser = dbUser ?? JSON.parse(localStorage.getItem(dbUserKey) ?? JSON.stringify({ saved: true })); ++ ++ const [userName, setUserName] = useState(lsUser.userId ? lsUser.userName : ''); ++ const [userPin, setUserPin] = useState(lsUser.userId ? lsUser.userPin : ''); ++ const [userSaved, setUserSaved] = useState(lsUser.saved); ++ const [signedIn, setSignedIn] = useState(lsUser.userId ? true : false); ++ const [errorMsg, setErrorMsg] = useState(''); ++ ++ const logIn = async () => { ++ setErrorMsg(''); ++ ++ const newDBUser: DBUser = { ++ saved: userSaved, ++ userName, ++ userPin, ++ }; ++ ++ if (userName && userPin) { ++ const authTest = await authUser({ userName, userPin }); ++ ++ if (authTest.success) { ++ newDBUser.userId = authTest.userId; ++ ++ setSignedIn(true); ++ changeDBUser(newDBUser); ++ ++ if (userSaved) { ++ localStorage.setItem(dbUserKey, JSON.stringify(newDBUser)); ++ } else { ++ localStorage.removeItem(dbUserKey); ++ dbUser = newDBUser; ++ } ++ } else { ++ setErrorMsg(authTest.errorMsg); ++ } ++ } ++ }; ++ ++ const logOut = () => { ++ dbUser = null; ++ setSignedIn(false); ++ changeDBUser(null); ++ localStorage.removeItem(dbUserKey); ++ setUserName(''); ++ setUserPin(''); ++ setUserSaved(false); ++ }; ++ ++ const enroll = async () => { ++ const newUser = await signUpUser(); ++ if (newUser) { ++ const tmpUser = JSON.parse(sessionStorage.getItem('tmpUser') ?? '') as AuthUser; ++ sessionStorage.removeItem('tmpUser'); ++ setUserName(tmpUser.userName); ++ setUserPin(tmpUser.userPin); ++ setUserSaved(true); ++ await logIn(); ++ } ++ }; ++ ++ useEffect(() => { ++ if (lsUser.userId) { ++ changeDBUser(lsUser); ++ } ++ // eslint-disable-next-line react-hooks/exhaustive-deps ++ }, []); ++ ++ return ( ++ <> ++ {renderModal()} ++
++
++  } ++ > ++ setUserName(data.value)} ++ maxLength={20} ++ disabled={signedIn} ++ /> ++ ++
++
++ ++ setUserPin(data.value)} ++ disabled={signedIn} ++ /> ++ ++
++
++ ++ {signedIn ? : } ++ ++ ++
++
++
++ ++ ++ ++
++ ++ ); ++}; ++ ++export const OpenDB: React.FC = ({ actions }) => { ++ const isDirty = useIsDirty(); ++ const loadScene = useLoadScene(); ++ const dismissDialog = useCloseDialog(); ++ ++ const [dbUser, setDBUser] = useState(null); ++ const [selectedRows, setSelectedRows] = useState(new Set()); ++ const [plans, setPlans] = useState([]); ++ ++ const [confirmUnsavedChanges, renderModal1] = useConfirmUnsavedChanges(); ++ const [confirmDeleteFile, renderModal2] = useConfirmDeleteFile(); ++ ++ const loadSceneFromDB = async (event: MouseEvent, folder: string, name: string, id: string) => { ++ if (isDirty && !(await confirmUnsavedChanges())) { ++ return; ++ } ++ ++ const source: FileSource = { type: 'db', folder, name, id }; ++ const scene = await openFile(source); ++ ++ localStorage.removeItem('tmpFolder'); ++ localStorage.removeItem('tmpName'); ++ ++ loadScene(scene, source); ++ dismissDialog(); ++ }; ++ ++ const openCallback = async (event: MouseEvent) => { ++ const [id] = selectedRows; ++ if (id) { ++ const plan = plans.filter((plan) => plan.id === id)[0]; ++ const name = plan?.name ?? ''; ++ const folder = plan?.folder ?? ''; ++ await loadSceneFromDB(event, folder, name, id as string); ++ } ++ }; ++ ++ const loadPlans = async (userId: string) => { ++ const newPlans = await getPlans(userId); ++ ++ if (newPlans.success) { ++ setPlans(newPlans.plans); ++ } else { ++ alert(`Error: ${newPlans.errorMsg}`); ++ } ++ }; ++ ++ useEffect(() => { ++ if (dbUser) { ++ // eslint-disable-next-line react-hooks/set-state-in-effect ++ loadPlans(dbUser.userId ?? ''); ++ } else { ++ setPlans([]); ++ } ++ }, [dbUser]); ++ ++ const onSelectionChange: DataGridProps['onSelectionChange'] = (ev, data) => { ++ setSelectedRows(data.selectedItems); ++ }; ++ ++ const tryDeletePlan = async (item: Plan) => { ++ if (await confirmDeleteFile(`${item.folder}${item.folder ? '/' : ''}${item.name}`)) { ++ const resp = await deletePlan( ++ { userName: dbUser?.userName ?? '', userPin: dbUser?.userPin ?? '' }, ++ item.id, ++ ); ++ if (resp.success) { ++ loadPlans(dbUser?.userId ?? ''); ++ } else { ++ alert(`Error: ${resp.errorMsg}`); ++ } ++ } ++ }; ++ ++ const tryRenamePlan = async (item: Plan) => { ++ const newName = prompt('Please enter a new name:'); ++ ++ if (newName?.trim()) { ++ const resp = await renamePlan( ++ { userName: dbUser?.userName ?? '', userPin: dbUser?.userPin ?? '' }, ++ item.id, ++ newName, ++ ); ++ if (resp.success) { ++ loadPlans(dbUser?.userId ?? ''); ++ } else { ++ alert(`Error: ${resp.errorMsg}`); ++ } ++ } ++ }; ++ ++ const tryMovePlan = async (item: Plan) => { ++ const newFolder = prompt('Please enter a new folder name:'); ++ ++ if (newFolder?.trim()) { ++ const resp = await movePlan( ++ { userName: dbUser?.userName ?? '', userPin: dbUser?.userPin ?? '' }, ++ item.id, ++ newFolder, ++ ); ++ if (resp.success) { ++ loadPlans(dbUser?.userId ?? ''); ++ } else { ++ alert(`Error: ${resp.errorMsg}`); ++ } ++ } ++ }; ++ ++ const columns: TableColumnDefinition[] = [ ++ createTableColumn({ ++ columnId: 'name', ++ compare: (a, b) => a.name.localeCompare(b.name), ++ renderHeaderCell: () => 'Folder/Name', ++ renderCell: (item) => `${item.folder}${item.folder ? '/' : ''}${item.name}`, ++ }), ++ createTableColumn({ ++ columnId: 'lastUpdate', ++ compare: (a, b) => { ++ const time1 = a.lastUpdated.getTime(); ++ const time2 = b.lastUpdated.getTime(); ++ return time1 - time2; ++ }, ++ renderHeaderCell: () => 'Last updated', ++ renderCell: (item) => item.lastUpdated.toLocaleString(), ++ }), ++ createTableColumn({ ++ columnId: 'delete', ++ renderHeaderCell: () => 'Actions', ++ renderCell: (item) => { ++ return ( ++ <> ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ); ++}; ++ ++function getInitialName(source: FileSource | undefined) { ++ return source?.type === 'db' ? source.name : ''; ++} ++function getInitialFolder(source: FileSource | undefined) { ++ return source?.type === 'db' ? source.folder : ''; ++} ++ ++export const SaveDB: React.FC = ({ actions }) => { ++ const setSavedState = useSetSavedState(); ++ const dismissDialog = useCloseDialog(); ++ ++ const setSource = useSetSource(); ++ const { canonicalScene, source } = useScene(); ++ ++ const [dbUser, setDBUser] = useState(null); ++ const [name, setName] = useState(getInitialName(source)); ++ const [folder, setFolder] = useState(getInitialFolder(source)); ++ ++ const canSave = !!name?.trim() && dbUser?.userName && dbUser.userPin; ++ ++ const [, save] = useAsyncFn(async () => { ++ if (!canSave) { ++ return; ++ } ++ ++ const source: FileSource = { ++ type: 'db', ++ name: name.trim(), ++ folder: folder?.trim(), ++ id: '', ++ userName: dbUser?.userName, ++ userPin: dbUser?.userPin, ++ }; ++ ++ const success = await saveFile(canonicalScene, source); ++ if (!success) return; ++ ++ setSource(source); ++ setSavedState(canonicalScene); ++ dismissDialog(); ++ }, [canonicalScene, name, canSave, dismissDialog, setSavedState, setSource]); ++ ++ return ( ++ <> ++ setDBUser(newDBUser)} /> ++ ++ setFolder(data.value)} /> ++ ++ ++ setName(data.value)} /> ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ); ++}; diff --git a/src/file/ShareDialogButton.tsx b/src/file/ShareDialogButton.tsx index d9cd04a..706f3bb 100644 --- a/src/file/ShareDialogButton.tsx @@ -297,6 +850,433 @@ index d9cd04a..706f3bb 100644 } const useStyles = makeStyles({ +diff --git a/src/file/SignUpPrompt.tsx b/src/file/SignUpPrompt.tsx +new file mode 100644 +index 0000000..a1e356f +--- /dev/null ++++ b/src/file/SignUpPrompt.tsx +@@ -0,0 +1,152 @@ ++import { ++ Button, ++ Dialog, ++ DialogActions, ++ DialogContent, ++ type DialogOpenChangeData, ++ type DialogOpenChangeEvent, ++ DialogSurface, ++ DialogTitle, ++ DialogTrigger, ++ Field, ++ Input, ++} from '@fluentui/react-components'; ++import React, { useId, useState } from 'react'; ++import { HotkeyBlockingDialogBody } from '../HotkeyBlockingDialogBody'; ++import { useAsyncModalResolveCallback } from '../useAsyncModal'; ++import type { FilePromptProps } from './FilePrompts'; ++import { enrollUser } from './db'; ++ ++export const SignUpPrompt: React.FC = ({ resolve, ...props }) => { ++ const confirmId = useId(); ++ const onOpenChange = useAsyncModalResolveCallback(confirmId, resolve); ++ ++ const [userName, setUserName] = useState(''); ++ const [userPin, setUserPin] = useState(''); ++ const [userConfirmPin, setUserConfirmPin] = useState(''); ++ const [userEmail, setEmail] = useState(''); ++ const [done, setDone] = useState(false); ++ const [errorMsg, setErrorMsg] = useState(''); ++ ++ const emailInvalid = userEmail && !userEmail.match(/^[^@]+@[^@]+\.[^@]+$/); ++ const canSignUp = ++ userName.length >= 4 && userPin.length >= 4 && userPin === userConfirmPin && (!userEmail || !emailInvalid); ++ ++ const tryEnroll = async (ev: React.MouseEvent) => { ++ setErrorMsg(''); ++ ev.preventDefault(); ++ const result = await enrollUser({ userName, userPin, userEmail }); ++ ++ if (result.success) { ++ setDone(true); ++ sessionStorage.setItem('tmpUser', JSON.stringify({ userName, userPin })); ++ } else { ++ setErrorMsg(result.errorMsg); ++ } ++ }; ++ ++ const reset = (event: DialogOpenChangeEvent, data: DialogOpenChangeData) => { ++ setDone(false); ++ setUserName(''); ++ setUserPin(''); ++ setUserConfirmPin(''); ++ setEmail(''); ++ onOpenChange(event, data); ++ }; ++ ++ return ( ++ ++ ++ ++ {done ? 'Success!' : 'Enroll New User'} ++ {!done && ( ++ ++  ) ++ } ++ > ++ setUserName(data.value)} ++ maxLength={20} ++ /> ++ ++  } ++ > ++ setUserPin(data.value)} ++ /> ++ ++   ++ ) ++ } ++ > ++ setUserConfirmPin(data.value)} ++ /> ++ ++  } ++ > ++ setEmail(data.value)} ++ maxLength={255} ++ /> ++ ++

++ I highly recommend signing up with an email. This adds a layer of protection to your ++ account to prevent accidentally deleting your entire account. This is all your email ++ address would be used for. ++

++
++ )} ++ ++ {done && ( ++ ++ ++ ++ )} ++ {!done && ( ++ <> ++ ++ ++ ++ ++ ++ ++ ++ )} ++ ++
++
++
++ ); ++}; +diff --git a/src/file/db.ts b/src/file/db.ts +new file mode 100644 +index 0000000..d59e84d +--- /dev/null ++++ b/src/file/db.ts +@@ -0,0 +1,249 @@ ++import type { Scene } from '../scene'; ++import type { DBSource } from '../SceneProvider'; ++ ++const genFail = 'Something went wrong, please try again.'; ++const baseApiPath = '/api/'; ++ ++interface ApiSuccess { ++ success: true; ++} ++interface ApiFail { ++ success: false; ++ errorMsg: string; ++} ++ ++export interface AuthUser { ++ userName: string; ++ userPin: string; ++ userEmail?: string; ++} ++interface AuthSuccess { ++ success: true; ++ userId: string; ++} ++export async function authUser(user: AuthUser): Promise { ++ let failed = false; ++ const authTest = await fetch(`${baseApiPath}auth`, { ++ method: 'POST', ++ body: JSON.stringify({ name: user.userName, pin: user.userPin }), ++ }).catch((err) => { ++ alert(err); ++ failed = true; ++ }); ++ if (!authTest || failed) return { success: false, errorMsg: genFail }; ++ if (authTest.status === 200) { ++ return { ++ success: true, ++ userId: (await authTest.json()).id, ++ }; ++ } else { ++ return { ++ success: false, ++ errorMsg: await authTest.text(), ++ }; ++ } ++} ++ ++interface BasePlan { ++ id: string; ++ name: string; ++ folder: string; ++} ++interface RawPlan extends BasePlan { ++ lastUpdated: number; ++} ++export interface Plan extends BasePlan { ++ lastUpdated: Date; ++} ++interface PlansSuccess { ++ success: true; ++ plans: Plan[]; ++} ++export async function getPlans(userId: string): Promise { ++ let failed = false; ++ const tryGetPlans = await fetch(`${baseApiPath}list/${userId}`).catch((err) => { ++ console.error(err); ++ failed = true; ++ }); ++ if (!tryGetPlans || failed) return { success: false, errorMsg: genFail }; ++ if (tryGetPlans.status === 200) { ++ const rawPlans = await tryGetPlans.json(); ++ const plans: Plan[] = []; ++ rawPlans.forEach((p: RawPlan) => { ++ plans.push({ ++ id: p.id, ++ name: p.name, ++ folder: p.folder, ++ lastUpdated: new Date(p.lastUpdated), ++ }); ++ }); ++ return { ++ success: true, ++ plans, ++ }; ++ } else { ++ return { ++ success: false, ++ errorMsg: await tryGetPlans.text(), ++ }; ++ } ++} ++ ++export async function deletePlan(user: AuthUser, planId: string): Promise { ++ let failed = false; ++ const tryDeletePlan = await fetch(`${baseApiPath}delete/${planId}`, { ++ method: 'DELETE', ++ body: JSON.stringify({ name: user.userName, pin: user.userPin }), ++ }).catch((err) => { ++ alert(err); ++ failed = true; ++ }); ++ if (!tryDeletePlan || failed) return { success: false, errorMsg: genFail }; ++ if (tryDeletePlan.status === 200) { ++ return { ++ success: true, ++ }; ++ } else { ++ return { ++ success: false, ++ errorMsg: await tryDeletePlan.text(), ++ }; ++ } ++} ++ ++export async function renamePlan(user: AuthUser, planId: string, newName: string): Promise { ++ let failed = false; ++ const tryRenamePlan = await fetch(`${baseApiPath}rename/${planId}`, { ++ method: 'PUT', ++ body: JSON.stringify({ name: user.userName, pin: user.userPin, planName: newName }), ++ }).catch((err) => { ++ alert(err); ++ failed = true; ++ }); ++ if (!tryRenamePlan || failed) return { success: false, errorMsg: genFail }; ++ if (tryRenamePlan.status === 200) { ++ return { ++ success: true, ++ }; ++ } else { ++ return { ++ success: false, ++ errorMsg: await tryRenamePlan.text(), ++ }; ++ } ++} ++ ++export async function movePlan(user: AuthUser, planId: string, newFolder: string): Promise { ++ let failed = false; ++ const tryMovePlan = await fetch(`${baseApiPath}move/${planId}`, { ++ method: 'PUT', ++ body: JSON.stringify({ name: user.userName, pin: user.userPin, folder: newFolder }), ++ }).catch((err) => { ++ alert(err); ++ failed = true; ++ }); ++ if (!tryMovePlan || failed) return { success: false, errorMsg: genFail }; ++ if (tryMovePlan.status === 200) { ++ return { ++ success: true, ++ }; ++ } else { ++ return { ++ success: false, ++ errorMsg: await tryMovePlan.text(), ++ }; ++ } ++} ++ ++export async function openFromDB(planId: string): Promise { ++ let failed = false; ++ const tryGetPlan = await fetch(`${baseApiPath}read/${planId}`).catch((err) => { ++ console.error(err); ++ failed = true; ++ }); ++ if (!tryGetPlan || failed) { ++ alert(genFail); ++ } else if (tryGetPlan.status === 200) { ++ const plan = await tryGetPlan.json(); ++ localStorage.setItem('tmpFolder', plan.folder); ++ localStorage.setItem('tmpName', plan.name); ++ return JSON.parse(plan.data) as Scene; ++ } else { ++ const errorMsg = await tryGetPlan.text(); ++ alert(`Failed to load: ${errorMsg}`); ++ } ++} ++ ++export async function saveToDB(scene: Readonly, source: DBSource): Promise { ++ let failed = false; ++ const trySave = await fetch(`${baseApiPath}update/${source.id}`, { ++ method: 'PUT', ++ body: JSON.stringify({ ++ name: source.userName, ++ pin: source.userPin, ++ data: JSON.stringify(scene), ++ }), ++ }).catch((err) => { ++ console.error(err); ++ failed = true; ++ }); ++ if (!trySave || failed) { ++ alert(genFail); ++ return false; ++ } else if (trySave.status !== 200) { ++ const errorMsg = await trySave.text(); ++ alert(`Failed to save: ${errorMsg}`); ++ return false; ++ } ++ return true; ++} ++ ++export async function saveNewToDB(scene: Readonly, source: DBSource): Promise { ++ let failed = false; ++ const tryCreate = await fetch(`${baseApiPath}create`, { ++ method: 'POST', ++ body: JSON.stringify({ ++ name: source.userName, ++ pin: source.userPin, ++ planName: source.name, ++ folder: source.folder, ++ data: JSON.stringify(scene), ++ }), ++ }).catch((err) => { ++ console.error(err); ++ failed = true; ++ }); ++ if (!tryCreate || failed) { ++ alert(genFail); ++ return false; ++ } else if (tryCreate.status !== 200) { ++ const errorMsg = await tryCreate.text(); ++ alert(`Failed to save: ${errorMsg}`); ++ return false; ++ } ++ source.id = (await tryCreate.json()).id; ++ return true; ++} ++ ++export async function enrollUser(user: AuthUser): Promise { ++ let failed = false; ++ const tryEnroll = await fetch(`${baseApiPath}enroll`, { ++ method: 'POST', ++ body: JSON.stringify({ name: user.userName, pin: user.userPin, email: user.userEmail }), ++ }).catch((err) => { ++ alert(err); ++ failed = true; ++ }); ++ if (!tryEnroll || failed) return { success: false, errorMsg: genFail }; ++ if (tryEnroll.status === 200) { ++ return { ++ success: true, ++ userId: (await tryEnroll.json()).id, ++ }; ++ } else { ++ return { ++ success: false, ++ errorMsg: await tryEnroll.text(), ++ }; ++ } ++} +diff --git a/src/file/signUp.tsx b/src/file/signUp.tsx +new file mode 100644 +index 0000000..b26b926 +--- /dev/null ++++ b/src/file/signUp.tsx +@@ -0,0 +1,8 @@ ++import { useAsyncModal } from '../useAsyncModal'; ++import { SignUpPrompt } from './SignUpPrompt'; ++ ++export function useSignUp() { ++ return useAsyncModal((resolve, props) => { ++ return ; ++ }); ++} diff --git a/vite.config.ts b/vite.config.ts index f44ac40..e6c7bb3 100644 --- a/vite.config.ts