add new files

This commit is contained in:
Ean Milligan
2026-04-23 17:33:10 -04:00
parent aab802b66a
commit f01a3e3abf

View File

@@ -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 : <>&nbsp;</>}
+ >
+ <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="&nbsp;">
+ {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&nbsp;&nbsp;
+ <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&trade;"
+ 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' : <>&nbsp;</>)
+ }
+ >
+ <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' : <>&nbsp;</>}
+ >
+ <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"
+ ) : (
+ <>&nbsp;</>
+ )
+ }
+ >
+ <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' : <>&nbsp;</>}
+ >
+ <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