diff --git a/src/AboutDialog.tsx b/src/AboutDialog.tsx index a346146..b4ef320 100644 --- a/src/AboutDialog.tsx +++ b/src/AboutDialog.tsx @@ -30,6 +30,13 @@ export const AboutDialog: React.FC = (props) => { About +

+ XIVPlan+DB is a minimal mod to the original{' '} + XIVPlan, simply adding a + very basic database behind it to make more accessible share links. This minimal mod is open + source and can be found{' '} + here. +

XIVPlan is a tool for quickly diagramming raid strategies for Final Fantasy XIV, inspired by{' '} RaidPlan.io and{' '} diff --git a/src/App.tsx b/src/App.tsx index 05a2fb7..f7ed022 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { FileOpenPage } from './FileOpenPage'; import { HelpProvider } from './HelpProvider'; import { MainPage } from './MainPage'; import { SceneProvider } from './SceneProvider'; +import { SharePage } from './SharePage'; import { SiteHeader } from './SiteHeader'; import { ThemeProvider } from './ThemeProvider'; import { useFileLoaderDropTarget } from './useFileLoader'; @@ -106,6 +107,7 @@ const router = createBrowserRouter( }> } /> } /> + } /> , ), ); diff --git a/src/MainPage.tsx b/src/MainPage.tsx index de674da..8de51f1 100644 --- a/src/MainPage.tsx +++ b/src/MainPage.tsx @@ -55,7 +55,7 @@ const MainPageContent: React.FC = () => { ); }; -const TITLE = 'XIVPlan'; +const TITLE = 'XIVPlan+DB'; function usePageTitle() { const { source } = useScene(); @@ -64,6 +64,7 @@ function usePageTitle() { let title = TITLE; if (source) { title += ': '; + if (source.type === 'db' && source.folder) title += `${source.folder}/`; title += removeFileExtension(source?.name); } if (isDirty) { diff --git a/src/MainToolbar.tsx b/src/MainToolbar.tsx index e876909..abd73f1 100644 --- a/src/MainToolbar.tsx +++ b/src/MainToolbar.tsx @@ -131,7 +131,8 @@ const SaveButton: React.FC = () => { if (!source) { setSaveAsOpen(true); } else if (isDirty) { - await saveFile(canonicalScene, source); + const success = await saveFile(canonicalScene, source); + if (!success) return; setSavedState(canonicalScene); } }; diff --git a/src/SceneProvider.tsx b/src/SceneProvider.tsx index e2565bb..e483e67 100644 --- a/src/SceneProvider.tsx +++ b/src/SceneProvider.tsx @@ -170,7 +170,16 @@ export interface BlobFileSource { file?: File; } -export type FileSource = LocalStorageFileSource | FileSystemFileSource | BlobFileSource; +export interface DBSource { + type: 'db'; + id: string; + name: string; + folder?: string; + userName?: string; + userPin?: string; +} + +export type FileSource = LocalStorageFileSource | FileSystemFileSource | BlobFileSource | DBSource; 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 +++ b/src/SiteHeader.tsx @@ -75,7 +75,7 @@ export const SiteHeader: React.FC> = ({ className, .

- XIVPlan + XIVPlan+DB {source && }
@@ -117,7 +117,10 @@ const SourceIndicator: React.FC = ({ source }) => { return ( - {removeFileExtension(source.name)} + + {source.type === 'db' && source.folder && `${source.folder}/`} + {removeFileExtension(source.name)} + {isDirty && } diff --git a/src/file.ts b/src/file.ts index bcc4a94..1a0453c 100644 --- a/src/file.ts +++ b/src/file.ts @@ -3,12 +3,13 @@ import { deflate, inflate } from 'pako'; import type { FileSource } from './SceneProvider'; import { downloadScene, openFileBlob } from './file/blob'; +import { authUser, getPlans, openFromDB, saveNewToDB, saveToDB } from './file/db'; import { openFileFs, saveFileFs } from './file/filesystem'; import { openFileLocalStorage, saveFileLocalStorage } from './file/localStorage'; import { upgradeScene } from './file/upgrade'; import type { Scene } from './scene'; -export async function saveFile(scene: Readonly, source: FileSource): Promise { +export async function saveFile(scene: Readonly, source: FileSource): Promise { switch (source.type) { case 'local': await saveFileLocalStorage(scene, source.name); @@ -21,6 +22,41 @@ export async function saveFile(scene: Readonly, source: FileSource): Prom case 'blob': downloadScene(scene, source.name); break; + + case 'db': { + if (!source.userName) { + source.userName = prompt('Please enter your username:') ?? undefined; + } + if (!source.userPin) { + source.userPin = prompt('Please enter your PIN:') ?? undefined; + } + if (!source.userName || !source.userPin) { + alert('Plan not saved, missing username or pin'); + throw new Error('user credentials not provided'); + } + + const tryAuthUser = await authUser({ userName: source.userName, userPin: source.userPin }); + if (!tryAuthUser.success) { + source.userName = ''; + source.userPin = ''; + alert('Plan not saved, Invalid credentials'); + throw new Error('user credentials invalid'); + } + + if (source.id) { + const myPlans = await getPlans(tryAuthUser.userId); + + if (!myPlans.success) { + alert('Plan not saved, Something went wrong'); + throw new Error("couldn't read plans"); + } + + if (myPlans.plans.filter((plan) => plan.id === source.id)) { + return await saveToDB(scene, source); + } + } + return await saveNewToDB(scene, source); + } } } @@ -42,6 +78,9 @@ async function openFileUnvalidated(source: FileSource) { throw new Error('File not set'); } return await openFileBlob(source.file); + + case 'db': + return (await openFromDB(source.id)) as Scene; } } diff --git a/src/file/FileDialog.tsx b/src/file/FileDialog.tsx index 63a473b..d32cb53 100644 --- a/src/file/FileDialog.tsx +++ b/src/file/FileDialog.tsx @@ -14,18 +14,20 @@ import React, { useState } from 'react'; import { OutPortal, createHtmlPortalNode } from 'react-reverse-portal'; import { HotkeyBlockingDialogBody } from '../HotkeyBlockingDialogBody'; import { TabActivity } from '../TabActivity'; +import { OpenDB, SaveDB } from './FileDialogDB'; import { FileSystemNotSupportedMessage, OpenFileSystem, SaveFileSystem } from './FileDialogFileSystem'; import { OpenLocalStorage, SaveLocalStorage } from './FileDialogLocalStorage'; import { ImportFromString } from './FileDialogShare'; import { supportsFs } from './filesystem'; -type Tabs = 'file' | 'localStorage' | 'import' | 'fileUnsupported'; +type Tabs = 'db' | 'file' | 'localStorage' | 'import' | 'fileUnsupported'; export type OpenDialogProps = Omit; +// TODO Add DB Tab export const OpenDialog: React.FC = (props) => { const classes = useStyles(); - const [tab, setTab] = useState(supportsFs ? 'file' : 'localStorage'); + const [tab, setTab] = useState('db'); const portalNode = createHtmlPortalNode({ attributes: { class: classes.actionsPortal } }); return ( @@ -40,11 +42,15 @@ export const OpenDialog: React.FC = (props) => { selectedValue={tab} onTabSelect={(ev, data) => setTab(data.value as Tabs)} > + Online DB {supportsFs && Local file} Browser storage Import plan link {!supportsFs && Local file} + + + @@ -71,7 +77,7 @@ export type SaveAsDialogProps = Omit; export const SaveAsDialog: React.FC = (props) => { const classes = useStyles(); - const [tab, setTab] = useState(supportsFs ? 'file' : 'localStorage'); + const [tab, setTab] = useState('db'); const portalNode = createHtmlPortalNode(); return ( @@ -86,10 +92,14 @@ export const SaveAsDialog: React.FC = (props) => { selectedValue={tab} onTabSelect={(ev, data) => setTab(data.value as Tabs)} > + Online DB {supportsFs && Local file} Browser storage {!supportsFs && Local file} + + + 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 +++ b/src/file/ShareDialogButton.tsx @@ -17,7 +17,7 @@ import { CopyRegular, ShareRegular } from '@fluentui/react-icons'; import React, { type ReactNode } from 'react'; import { CollapsableToolbarButton } from '../CollapsableToolbarButton'; import { HotkeyBlockingDialogBody } from '../HotkeyBlockingDialogBody'; -import { useScene } from '../SceneProvider'; +import { type FileSource, useScene } from '../SceneProvider'; import { sceneToText } from '../file'; import type { Scene } from '../scene'; import { DownloadButton } from './DownloadButton'; @@ -42,9 +42,9 @@ export const ShareDialogButton: React.FC = ({ children } const ShareDialogBody: React.FC = () => { const classes = useStyles(); - const { canonicalScene } = useScene(); + const { canonicalScene, source } = useScene(); const { dispatchToast } = useToastController(); - const url = getSceneUrl(canonicalScene); + const url = getSceneUrl(canonicalScene, source); const copyToClipboard = async () => { await navigator.clipboard.writeText(url); @@ -87,9 +87,13 @@ const CopySuccessToast = () => { ); }; -function getSceneUrl(scene: Scene) { +function getSceneUrl(scene: Scene, source: FileSource | undefined) { const data = sceneToText(scene); - return `${location.protocol}//${location.host}${location.pathname}#/plan/${data}`; + if (source && source.type === 'db') { + return `${location.protocol}//${location.host}${location.pathname}share#${source.id}`; + } else { + return `${location.protocol}//${location.host}${location.pathname}#/plan/${data}`; + } } 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 +++ b/vite.config.ts @@ -22,6 +22,14 @@ function getEnvOptions(mode: string): UserConfig { export default defineConfig(({ mode }) => ({ ...getEnvOptions(mode), + server: { + proxy: { + '/api': { + target: 'http://localhost:14014', + changeOrigin: true, + }, + }, + }, plugins: [ react(), babel({