1299 lines
48 KiB
Diff
1299 lines
48 KiB
Diff
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<AboutDialogProps> = (props) => {
|
|
<HotkeyBlockingDialogBody>
|
|
<DialogTitle>About</DialogTitle>
|
|
<DialogContent className={classes.content}>
|
|
+ <p>
|
|
+ XIVPlan+DB is a minimal mod to the original{' '}
|
|
+ <ExternalLink href="https://xivplan.netlify.app/">XIVPlan</ExternalLink>, simply adding a
|
|
+ very basic database behind it to make more accessible share links. This minimal mod is open
|
|
+ source and can be found{' '}
|
|
+ <ExternalLink href="https://git.milligan.dev/xivdev/XIVPlan-DB">here</ExternalLink>.
|
|
+ </p>
|
|
<p>
|
|
XIVPlan is a tool for quickly diagramming raid strategies for Final Fantasy XIV, inspired by{' '}
|
|
<ExternalLink href="https://raidplan.io">RaidPlan.io</ExternalLink> 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(
|
|
<Route path="/" element={<Layout />}>
|
|
<Route index element={<MainPage />} />
|
|
<Route path="open" element={<FileOpenPage />} />
|
|
+ <Route path="share" element={<SharePage />} />
|
|
</Route>,
|
|
),
|
|
);
|
|
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<HTMLAttributes<HTMLElement>> = ({ className, .
|
|
<header className={mergeClasses(classes.root, className)} {...props}>
|
|
<div className={classes.title}>
|
|
<Text size={titleSize} weight="semibold">
|
|
- XIVPlan
|
|
+ XIVPlan+DB
|
|
</Text>
|
|
{source && <SourceIndicator source={source} />}
|
|
</div>
|
|
@@ -117,7 +117,10 @@ const SourceIndicator: React.FC<SourceIndicatorProps> = ({ source }) => {
|
|
return (
|
|
<Tooltip content={tooltip} relationship="description">
|
|
<span className={classes.source}>
|
|
- <Text className={classes.filename}>{removeFileExtension(source.name)}</Text>
|
|
+ <Text className={classes.filename}>
|
|
+ {source.type === 'db' && source.folder && `${source.folder}/`}
|
|
+ {removeFileExtension(source.name)}
|
|
+ </Text>
|
|
{isDirty && <Text className={classes.dirty}>●</Text>}
|
|
</span>
|
|
</Tooltip>
|
|
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<Scene>, source: FileSource): Promise<void> {
|
|
+export async function saveFile(scene: Readonly<Scene>, source: FileSource): Promise<void | boolean> {
|
|
switch (source.type) {
|
|
case 'local':
|
|
await saveFileLocalStorage(scene, source.name);
|
|
@@ -21,6 +22,41 @@ export async function saveFile(scene: Readonly<Scene>, 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<DialogProps, 'children'>;
|
|
|
|
+// TODO Add DB Tab
|
|
export const OpenDialog: React.FC<OpenDialogProps> = (props) => {
|
|
const classes = useStyles();
|
|
- const [tab, setTab] = useState<Tabs>(supportsFs ? 'file' : 'localStorage');
|
|
+ const [tab, setTab] = useState<Tabs>('db');
|
|
const portalNode = createHtmlPortalNode({ attributes: { class: classes.actionsPortal } });
|
|
|
|
return (
|
|
@@ -40,11 +42,15 @@ export const OpenDialog: React.FC<OpenDialogProps> = (props) => {
|
|
selectedValue={tab}
|
|
onTabSelect={(ev, data) => setTab(data.value as Tabs)}
|
|
>
|
|
+ <Tab value="db">Online DB</Tab>
|
|
{supportsFs && <Tab value="file">Local file</Tab>}
|
|
<Tab value="localStorage">Browser storage</Tab>
|
|
<Tab value="import">Import plan link</Tab>
|
|
{!supportsFs && <Tab value="fileUnsupported">Local file</Tab>}
|
|
</TabList>
|
|
+ <TabActivity value="db" activeTab={tab}>
|
|
+ <OpenDB actions={portalNode} />
|
|
+ </TabActivity>
|
|
<TabActivity value="file" activeTab={tab}>
|
|
<OpenFileSystem actions={portalNode} />
|
|
</TabActivity>
|
|
@@ -71,7 +77,7 @@ export type SaveAsDialogProps = Omit<DialogProps, 'children'>;
|
|
|
|
export const SaveAsDialog: React.FC<SaveAsDialogProps> = (props) => {
|
|
const classes = useStyles();
|
|
- const [tab, setTab] = useState<Tabs>(supportsFs ? 'file' : 'localStorage');
|
|
+ const [tab, setTab] = useState<Tabs>('db');
|
|
const portalNode = createHtmlPortalNode();
|
|
|
|
return (
|
|
@@ -86,10 +92,14 @@ export const SaveAsDialog: React.FC<SaveAsDialogProps> = (props) => {
|
|
selectedValue={tab}
|
|
onTabSelect={(ev, data) => setTab(data.value as Tabs)}
|
|
>
|
|
+ <Tab value="db">Online DB</Tab>
|
|
{supportsFs && <Tab value="file">Local file</Tab>}
|
|
<Tab value="localStorage">Browser storage</Tab>
|
|
{!supportsFs && <Tab value="fileUnsupported">Local file</Tab>}
|
|
</TabList>
|
|
+ <TabActivity value="db" activeTab={tab}>
|
|
+ <SaveDB actions={portalNode} />
|
|
+ </TabActivity>
|
|
<TabActivity value="file" activeTab={tab}>
|
|
<SaveFileSystem actions={portalNode} />
|
|
</TabActivity>
|
|
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<LoginSectionProps> = ({ 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()}
|
|
+ <div style={{ display: 'flex', alignItems: '', justifyContent: 'space-between' }}>
|
|
+ <div>
|
|
+ <Field
|
|
+ label="Username"
|
|
+ validationState={errorMsg ? 'error' : 'none'}
|
|
+ validationMessage={errorMsg ? errorMsg : <> </>}
|
|
+ >
|
|
+ <Input
|
|
+ style={{ width: '20rem' }}
|
|
+ type="text"
|
|
+ value={userName}
|
|
+ onChange={(_ev, data) => setUserName(data.value)}
|
|
+ maxLength={20}
|
|
+ disabled={signedIn}
|
|
+ />
|
|
+ </Field>
|
|
+ </div>
|
|
+ <div>
|
|
+ <Field label="PIN" validationState={errorMsg ? 'error' : 'none'}>
|
|
+ <Input
|
|
+ style={{ width: '8rem' }}
|
|
+ type="password"
|
|
+ value={userPin}
|
|
+ onChange={(_ev, data) => setUserPin(data.value)}
|
|
+ disabled={signedIn}
|
|
+ />
|
|
+ </Field>
|
|
+ </div>
|
|
+ <div>
|
|
+ <Field label=" ">
|
|
+ {signedIn ? <Button onClick={logOut}>Log Out</Button> : <Button onClick={logIn}>Log In</Button>}
|
|
+ <Button onClick={enroll} disabled={signedIn}>
|
|
+ Sign Up
|
|
+ </Button>
|
|
+ </Field>
|
|
+ </div>
|
|
+ </div>
|
|
+ <div>
|
|
+ <Tooltip
|
|
+ appearance="inverted"
|
|
+ showDelay={0}
|
|
+ relationship="label"
|
|
+ content="Notice: Checking this will save your username and password to this browser."
|
|
+ withArrow
|
|
+ >
|
|
+ <Label>
|
|
+ <Checkbox
|
|
+ checked={userSaved}
|
|
+ onChange={(_ev, data) => setUserSaved(data.checked as boolean)}
|
|
+ disabled={signedIn}
|
|
+ />
|
|
+ Remember Me
|
|
+ <InfoIcon />
|
|
+ </Label>
|
|
+ </Tooltip>
|
|
+ </div>
|
|
+ </>
|
|
+ );
|
|
+};
|
|
+
|
|
+export const OpenDB: React.FC<SaveDBProps> = ({ actions }) => {
|
|
+ const isDirty = useIsDirty();
|
|
+ const loadScene = useLoadScene();
|
|
+ const dismissDialog = useCloseDialog();
|
|
+
|
|
+ const [dbUser, setDBUser] = useState<DBUser | null>(null);
|
|
+ const [selectedRows, setSelectedRows] = useState(new Set<TableRowId>());
|
|
+ const [plans, setPlans] = useState<Plan[]>([]);
|
|
+
|
|
+ const [confirmUnsavedChanges, renderModal1] = useConfirmUnsavedChanges();
|
|
+ const [confirmDeleteFile, renderModal2] = useConfirmDeleteFile();
|
|
+
|
|
+ const loadSceneFromDB = async (event: MouseEvent<HTMLElement>, 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<HTMLElement>) => {
|
|
+ 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<Plan>[] = [
|
|
+ createTableColumn<Plan>({
|
|
+ columnId: 'name',
|
|
+ compare: (a, b) => a.name.localeCompare(b.name),
|
|
+ renderHeaderCell: () => 'Folder/Name',
|
|
+ renderCell: (item) => `${item.folder}${item.folder ? '/' : ''}${item.name}`,
|
|
+ }),
|
|
+ createTableColumn<Plan>({
|
|
+ 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<Plan>({
|
|
+ columnId: 'delete',
|
|
+ renderHeaderCell: () => 'Actions',
|
|
+ renderCell: (item) => {
|
|
+ return (
|
|
+ <>
|
|
+ <Tooltip
|
|
+ content={`Delete ${item.folder}${item.folder ? '/' : ''}${item.name}`}
|
|
+ appearance="inverted"
|
|
+ relationship="label"
|
|
+ withArrow
|
|
+ >
|
|
+ <Button
|
|
+ appearance="subtle"
|
|
+ aria-label="Delete"
|
|
+ icon={<DeleteIcon />}
|
|
+ onClick={() => tryDeletePlan(item)}
|
|
+ />
|
|
+ </Tooltip>
|
|
+ <Tooltip content={`Rename ${item.name}`} appearance="inverted" relationship="label" withArrow>
|
|
+ <Button
|
|
+ appearance="subtle"
|
|
+ aria-label="Rename"
|
|
+ icon={<EditIcon />}
|
|
+ onClick={() => tryRenamePlan(item)}
|
|
+ />
|
|
+ </Tooltip>
|
|
+ <Tooltip content={`Move to new folder`} appearance="inverted" relationship="label" withArrow>
|
|
+ <Button
|
|
+ appearance="subtle"
|
|
+ aria-label="Move"
|
|
+ icon={<OpenFolderHorizontalIcon />}
|
|
+ onClick={() => tryMovePlan(item)}
|
|
+ />
|
|
+ </Tooltip>
|
|
+ </>
|
|
+ );
|
|
+ },
|
|
+ }),
|
|
+ ];
|
|
+
|
|
+ return (
|
|
+ <>
|
|
+ <LoginSection changeDBUser={(newDBUser) => setDBUser(newDBUser)} />
|
|
+ {dbUser?.userId && (
|
|
+ <>
|
|
+ <DataGrid
|
|
+ items={plans}
|
|
+ columns={columns}
|
|
+ getRowId={(item: Plan) => item.id}
|
|
+ size="small"
|
|
+ selectionMode="single"
|
|
+ selectedItems={selectedRows}
|
|
+ onSelectionChange={onSelectionChange}
|
|
+ subtleSelection
|
|
+ sortable
|
|
+ >
|
|
+ <DataGridHeader>
|
|
+ <DataGridRow>
|
|
+ {({ renderHeaderCell }) => (
|
|
+ <DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
|
|
+ )}
|
|
+ </DataGridRow>
|
|
+ </DataGridHeader>
|
|
+ <DataGridBody<Plan>
|
|
+ style={{
|
|
+ height: '40vh',
|
|
+ overflowY: 'auto',
|
|
+ }}
|
|
+ >
|
|
+ {({ item, rowId }) => (
|
|
+ <DataGridRow<Plan>
|
|
+ key={rowId}
|
|
+ selectionCell={{ radioIndicator: { 'aria-label': 'Select row' } }}
|
|
+ onDoubleClick={(ev: MouseEvent<HTMLElement>) =>
|
|
+ loadSceneFromDB(ev, item.folder, item.name, rowId as string)
|
|
+ }
|
|
+ >
|
|
+ {({ renderCell, columnId }) => (
|
|
+ <DataGridCell focusMode={getCellFocusMode(columnId)}>
|
|
+ {renderCell(item)}
|
|
+ </DataGridCell>
|
|
+ )}
|
|
+ </DataGridRow>
|
|
+ )}
|
|
+ </DataGridBody>
|
|
+ </DataGrid>
|
|
+ <p>
|
|
+ Need to restore a deleted plan or delete your account? Please{' '}
|
|
+ <a href={`/api/home/${dbUser?.userId}`} target="_blank" rel="noreferrer">
|
|
+ go here
|
|
+ </a>
|
|
+ .
|
|
+ </p>
|
|
+ </>
|
|
+ )}
|
|
+ {renderModal1()}
|
|
+ {renderModal2()}
|
|
+ <InPortal node={actions}>
|
|
+ <DialogActions fluid style={{ width: '100%' }}>
|
|
+ <Tooltip
|
|
+ appearance="inverted"
|
|
+ showDelay={0}
|
|
+ relationship="label"
|
|
+ content="Coming Soon™"
|
|
+ withArrow
|
|
+ >
|
|
+ <Button icon={<ArrowDownloadRegular />} style={{ marginRight: 'auto' }} disabled>
|
|
+ Export all
|
|
+ </Button>
|
|
+ </Tooltip>
|
|
+ <Button appearance="primary" disabled={!dbUser || selectedRows.size === 0} onClick={openCallback}>
|
|
+ Open
|
|
+ </Button>
|
|
+ <DialogTrigger>
|
|
+ <Button>Cancel</Button>
|
|
+ </DialogTrigger>
|
|
+ </DialogActions>
|
|
+ </InPortal>
|
|
+ </>
|
|
+ );
|
|
+};
|
|
+
|
|
+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<SaveDBProps> = ({ actions }) => {
|
|
+ const setSavedState = useSetSavedState();
|
|
+ const dismissDialog = useCloseDialog();
|
|
+
|
|
+ const setSource = useSetSource();
|
|
+ const { canonicalScene, source } = useScene();
|
|
+
|
|
+ const [dbUser, setDBUser] = useState<DBUser | null>(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 (
|
|
+ <>
|
|
+ <LoginSection changeDBUser={(newDBUser) => setDBUser(newDBUser)} />
|
|
+ <Field label="Folder name (optional)">
|
|
+ <Input type="text" autoFocus value={folder} onChange={(ev, data) => setFolder(data.value)} />
|
|
+ </Field>
|
|
+ <Field label="File name">
|
|
+ <Input type="text" autoFocus value={name} onChange={(ev, data) => setName(data.value)} />
|
|
+ </Field>
|
|
+
|
|
+ <InPortal node={actions}>
|
|
+ <DialogActions>
|
|
+ <Button appearance="primary" disabled={!canSave} onClick={save}>
|
|
+ Save as
|
|
+ </Button>
|
|
+ <DialogTrigger>
|
|
+ <Button>Cancel</Button>
|
|
+ </DialogTrigger>
|
|
+ </DialogActions>
|
|
+ </InPortal>
|
|
+ </>
|
|
+ );
|
|
+};
|
|
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<ShareDialogButtonProps> = ({ 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<FilePromptProps> = ({ 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<HTMLButtonElement, 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 (
|
|
+ <Dialog {...props} onOpenChange={reset}>
|
|
+ <DialogSurface backdrop={{ appearance: 'dimmed' }}>
|
|
+ <HotkeyBlockingDialogBody>
|
|
+ <DialogTitle>{done ? 'Success!' : 'Enroll New User'}</DialogTitle>
|
|
+ {!done && (
|
|
+ <DialogContent>
|
|
+ <Field
|
|
+ label="Username"
|
|
+ validationState={errorMsg || (userName && userName.length < 4) ? 'error' : 'none'}
|
|
+ validationMessage={
|
|
+ errorMsg || (userName && userName.length < 4 ? 'Name too short' : <> </>)
|
|
+ }
|
|
+ >
|
|
+ <Input
|
|
+ type="text"
|
|
+ value={userName}
|
|
+ onChange={(_ev, data) => setUserName(data.value)}
|
|
+ maxLength={20}
|
|
+ />
|
|
+ </Field>
|
|
+ <Field
|
|
+ label="PIN"
|
|
+ validationState={userPin && userPin.length < 4 ? 'error' : 'none'}
|
|
+ validationMessage={userPin && userPin.length < 4 ? 'PIN too short' : <> </>}
|
|
+ >
|
|
+ <Input
|
|
+ type="password"
|
|
+ value={userPin}
|
|
+ onChange={(_ev, data) => setUserPin(data.value)}
|
|
+ />
|
|
+ </Field>
|
|
+ <Field
|
|
+ label="Confirm PIN"
|
|
+ validationState={
|
|
+ userPin && userConfirmPin && userPin !== userConfirmPin ? 'error' : 'none'
|
|
+ }
|
|
+ validationMessage={
|
|
+ userPin && userConfirmPin && userPin !== userConfirmPin ? (
|
|
+ "PIN doesn't match"
|
|
+ ) : (
|
|
+ <> </>
|
|
+ )
|
|
+ }
|
|
+ >
|
|
+ <Input
|
|
+ type="password"
|
|
+ value={userConfirmPin}
|
|
+ onChange={(_ev, data) => setUserConfirmPin(data.value)}
|
|
+ />
|
|
+ </Field>
|
|
+ <Field
|
|
+ label="Email (optional)"
|
|
+ validationState={emailInvalid ? 'error' : 'none'}
|
|
+ validationMessage={emailInvalid ? 'Email Address invalid' : <> </>}
|
|
+ >
|
|
+ <Input
|
|
+ type="email"
|
|
+ value={userEmail}
|
|
+ onChange={(_ev, data) => setEmail(data.value)}
|
|
+ maxLength={255}
|
|
+ />
|
|
+ </Field>
|
|
+ <p>
|
|
+ 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.
|
|
+ </p>
|
|
+ </DialogContent>
|
|
+ )}
|
|
+ <DialogActions>
|
|
+ {done && (
|
|
+ <DialogTrigger>
|
|
+ <Button id={confirmId} appearance="primary">
|
|
+ Return to XIVPlan+DB
|
|
+ </Button>
|
|
+ </DialogTrigger>
|
|
+ )}
|
|
+ {!done && (
|
|
+ <>
|
|
+ <DialogTrigger>
|
|
+ <Button appearance="primary" disabled={!canSignUp} onClick={tryEnroll}>
|
|
+ Sign Up
|
|
+ </Button>
|
|
+ </DialogTrigger>
|
|
+ <DialogTrigger>
|
|
+ <Button>Cancel</Button>
|
|
+ </DialogTrigger>
|
|
+ </>
|
|
+ )}
|
|
+ </DialogActions>
|
|
+ </HotkeyBlockingDialogBody>
|
|
+ </DialogSurface>
|
|
+ </Dialog>
|
|
+ );
|
|
+};
|
|
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<AuthSuccess | ApiFail> {
|
|
+ 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<PlansSuccess | ApiFail> {
|
|
+ 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<ApiSuccess | ApiFail> {
|
|
+ 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<ApiSuccess | ApiFail> {
|
|
+ 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<ApiSuccess | ApiFail> {
|
|
+ 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<Scene | undefined> {
|
|
+ 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<Scene>, source: DBSource): Promise<boolean> {
|
|
+ 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<Scene>, source: DBSource): Promise<boolean> {
|
|
+ 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<AuthSuccess | ApiFail> {
|
|
+ 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 <SignUpPrompt resolve={resolve} {...props} />;
|
|
+ });
|
|
+}
|
|
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({
|