add new files
This commit is contained in:
980
db-version.patch
980
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
|
||||
<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
|
||||
@@ -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<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
|
||||
|
||||
Reference in New Issue
Block a user