Files
XIVPlan-DB/db-version.patch
Ean Milligan f01a3e3abf add new files
2026-04-23 17:33:10 -04:00

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 : <>&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
+++ 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' : <>&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
+++ 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({