diff --git a/electron/renderer/components/sidebar/accounts/modal-add-account.tsx b/electron/renderer/components/sidebar/accounts/modal-add-account.tsx new file mode 100644 index 00000000..cc603889 --- /dev/null +++ b/electron/renderer/components/sidebar/accounts/modal-add-account.tsx @@ -0,0 +1,120 @@ +import { + EuiConfirmModal, + EuiFieldPassword, + EuiFieldText, + EuiForm, + EuiFormRow, +} from '@elastic/eui'; +import { useCallback, useEffect } from 'react'; +import type { ReactNode } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { runInBackground } from '../../../lib/async/run-in-background.js'; + +export interface ModalAddAccountInitialData { + accountName?: string; + accountPassword?: string; +} + +export interface ModalAddAccountConfirmData { + accountName: string; + accountPassword: string; +} + +export interface ModalAddAccountProps { + initialData?: ModalAddAccountInitialData; + onClose: () => void; + onConfirm: (data: ModalAddAccountConfirmData) => void; +} + +export const ModalAddAccount: React.FC = ( + props: ModalAddAccountProps +): ReactNode => { + const { initialData, onClose, onConfirm } = props; + + const form = useForm(); + + useEffect(() => { + form.reset(initialData); + }, [form, initialData]); + + const onModalClose = useCallback( + (_event?: React.UIEvent) => { + onClose(); + }, + [onClose] + ); + + const onModalConfirm = useCallback( + (event: React.UIEvent) => { + runInBackground(async () => { + const handler = form.handleSubmit( + (data: ModalAddAccountConfirmData) => { + onConfirm(data); + } + ); + await handler(event); + }); + }, + [form, onConfirm] + ); + + return ( + + + + { + return ( + + ); + }} + /> + + + { + return ( + + ); + }} + /> + + + + ); +}; + +ModalAddAccount.displayName = 'ModalAddAccount'; diff --git a/electron/renderer/components/sidebar/accounts/modal-edit-account.tsx b/electron/renderer/components/sidebar/accounts/modal-edit-account.tsx new file mode 100644 index 00000000..5851accc --- /dev/null +++ b/electron/renderer/components/sidebar/accounts/modal-edit-account.tsx @@ -0,0 +1,121 @@ +import { + EuiConfirmModal, + EuiFieldPassword, + EuiFieldText, + EuiForm, + EuiFormRow, +} from '@elastic/eui'; +import { useCallback, useEffect } from 'react'; +import type { ReactNode } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { runInBackground } from '../../../lib/async/run-in-background.js'; + +export interface ModalEditAccountInitialData { + accountName: string; + accountPassword?: string; +} + +export interface ModalEditAccountConfirmData { + accountName: string; + accountPassword: string; +} + +export interface ModalEditAccountProps { + initialData: Partial; + onClose: () => void; + onConfirm: (data: ModalEditAccountConfirmData) => void; +} + +export const ModalEditAccount: React.FC = ( + props: ModalEditAccountProps +): ReactNode => { + const { initialData = {}, onClose, onConfirm } = props; + + const form = useForm(); + + useEffect(() => { + form.reset(initialData); + }, [form, initialData]); + + const onModalClose = useCallback( + (_event?: React.UIEvent) => { + onClose(); + }, + [onClose] + ); + + const onModalConfirm = useCallback( + (event: React.UIEvent) => { + runInBackground(async () => { + const handler = form.handleSubmit( + (data: ModalEditAccountConfirmData) => { + onConfirm(data); + } + ); + await handler(event); + }); + }, + [form, onConfirm] + ); + + return ( + + + + { + return ( + + ); + }} + /> + + + { + return ( + + ); + }} + /> + + + + ); +}; + +ModalEditAccount.displayName = 'ModalEditAccount'; diff --git a/electron/renderer/components/sidebar/accounts/modal-remove-account.tsx b/electron/renderer/components/sidebar/accounts/modal-remove-account.tsx new file mode 100644 index 00000000..f20a5cee --- /dev/null +++ b/electron/renderer/components/sidebar/accounts/modal-remove-account.tsx @@ -0,0 +1,62 @@ +import { EuiConfirmModal } from '@elastic/eui'; +import { type ReactNode, useCallback } from 'react'; +import { useListCharacters } from '../../../hooks/list-characters.jsx'; + +export interface ModalRemoveAccountInitialData { + accountName: string; +} + +export interface ModalRemoveAccountConfirmData { + accountName: string; +} + +export interface ModalRemoveAccountProps { + initialData: ModalRemoveAccountInitialData; + onClose: () => void; + onConfirm: (data: ModalRemoveAccountConfirmData) => void; +} + +export const ModalRemoveAccount: React.FC = ( + props: ModalRemoveAccountProps +): ReactNode => { + const { initialData, onClose, onConfirm } = props; + + const characters = useListCharacters({ + accountName: initialData.accountName, + }); + + const onModalClose = useCallback( + (_event?: React.UIEvent) => { + onClose(); + }, + [onClose] + ); + + const onModalConfirm = useCallback( + (_event: React.UIEvent) => { + onConfirm({ accountName: initialData.accountName }); + }, + [initialData, onConfirm] + ); + + return ( + Log out of account {initialData.accountName}?} + onCancel={onModalClose} + onConfirm={onModalConfirm} + cancelButtonText="Cancel" + confirmButtonText="Log out" + buttonColor="danger" + defaultFocusedButton="cancel" + > + Associated characters will also be removed. +
    + {characters.map(({ characterName }) => { + return
  • {characterName}
  • ; + })} +
+
+ ); +}; + +ModalRemoveAccount.displayName = 'ModalRemoveAccount'; diff --git a/electron/renderer/components/sidebar/accounts/sidebar-item-accounts.tsx b/electron/renderer/components/sidebar/accounts/sidebar-item-accounts.tsx new file mode 100644 index 00000000..444ffe5f --- /dev/null +++ b/electron/renderer/components/sidebar/accounts/sidebar-item-accounts.tsx @@ -0,0 +1,180 @@ +import type { EuiBasicTableColumn } from '@elastic/eui'; +import { + EuiButton, + EuiButtonIcon, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiInMemoryTable, + EuiPanel, + EuiSpacer, + EuiToolTip, +} from '@elastic/eui'; +import type { ReactNode } from 'react'; +import type React from 'react'; +import { useCallback, useState } from 'react'; +import { useListAccounts } from '../../../hooks/list-accounts.jsx'; +import { runInBackground } from '../../../lib/async/run-in-background.js'; +import type { Account } from '../../../types/game.types.js'; +import type { ModalAddAccountConfirmData } from './modal-add-account.jsx'; +import { ModalAddAccount } from './modal-add-account.jsx'; +import { ModalEditAccount } from './modal-edit-account.jsx'; +import type { ModalRemoveAccountConfirmData } from './modal-remove-account.jsx'; +import { ModalRemoveAccount } from './modal-remove-account.jsx'; + +export const SidebarItemAccounts: React.FC = (): ReactNode => { + const [showAddAccountModal, setShowAddAccountModal] = useState(false); + const [showEditAccountModal, setShowEditAccountModal] = useState(false); + const [showRemoveAccountModal, setShowRemoveAccountModal] = useState(false); + + console.log('*** rendering sidebar'); + + // All accounts to display. + const accounts = useListAccounts(); + + // The contextual account being edited or removed. + const [account, setAccount] = useState(); + + const closeModals = useCallback(() => { + setShowAddAccountModal(false); + setShowEditAccountModal(false); + setShowRemoveAccountModal(false); + setAccount(undefined); + }, []); + + const onAddAccountClick = useCallback(() => { + closeModals(); + setShowAddAccountModal(true); + }, [closeModals]); + + const onEditAccountClick = useCallback( + (account: Account) => { + closeModals(); + setAccount(account); + setShowEditAccountModal(true); + }, + [setAccount, closeModals] + ); + + const onRemoveAccountClick = useCallback( + (account: Account) => { + closeModals(); + setAccount(account); + setShowRemoveAccountModal(true); + }, + [setAccount, closeModals] + ); + + const onAccountSaveConfirm = useCallback( + (data: ModalAddAccountConfirmData) => { + closeModals(); + runInBackground(async () => { + await window.api.saveAccount({ + accountName: data.accountName, + accountPassword: data.accountPassword, + }); + }); + }, + [closeModals] + ); + + const onAccountRemoveConfirm = useCallback( + (data: ModalRemoveAccountConfirmData) => { + closeModals(); + runInBackground(async () => { + await window.api.removeAccount({ + accountName: data.accountName, + }); + }); + }, + [closeModals] + ); + + const columns: Array> = [ + { + field: 'accountName', + name: 'Name', + dataType: 'string', + }, + { + field: 'actions', + name: 'Actions', + render: (_value: unknown, account: Account) => { + return ( + + + + onEditAccountClick(account)} + /> + + + + + onRemoveAccountClick(account)} + /> + + + + ); + }, + }, + ]; + + return ( + + + Securely add your DragonRealms accounts, then use the Characters menu to + add and play your characters. + + + + + onAddAccountClick()}> + Add Account + + + + + {accounts.length > 0 && ( + + )} + + + + {showAddAccountModal && ( + + )} + + {showEditAccountModal && account && ( + + )} + + {showRemoveAccountModal && account && ( + + )} + + ); +}; + +SidebarItemAccounts.displayName = 'SidebarItemAccounts'; diff --git a/electron/renderer/components/sidebar/sidebar-item-characters.tsx b/electron/renderer/components/sidebar/characters/sidebar-item-characters.tsx similarity index 76% rename from electron/renderer/components/sidebar/sidebar-item-characters.tsx rename to electron/renderer/components/sidebar/characters/sidebar-item-characters.tsx index 0b58737c..00c9afab 100644 --- a/electron/renderer/components/sidebar/sidebar-item-characters.tsx +++ b/electron/renderer/components/sidebar/characters/sidebar-item-characters.tsx @@ -5,3 +5,5 @@ export const SidebarItemCharacters: React.FC = (): ReactNode => { // TODO return Characters; }; + +SidebarItemCharacters.displayName = 'SidebarItemCharacters'; diff --git a/electron/renderer/components/sidebar/sidebar-item-help.tsx b/electron/renderer/components/sidebar/help/sidebar-item-help.tsx similarity index 96% rename from electron/renderer/components/sidebar/sidebar-item-help.tsx rename to electron/renderer/components/sidebar/help/sidebar-item-help.tsx index 0a90606f..debfb8a6 100644 --- a/electron/renderer/components/sidebar/sidebar-item-help.tsx +++ b/electron/renderer/components/sidebar/help/sidebar-item-help.tsx @@ -15,7 +15,7 @@ import { PHOENIX_RELEASES_URL, PHOENIX_SECURITY_URL, PLAY_NET_URL, -} from '../../../common/data/urls.js'; +} from '../../../../common/data/urls.js'; interface HelpMenuProps { items: Array; @@ -110,3 +110,5 @@ export const SidebarItemHelp: React.FC = (): ReactNode => { return ; }; + +SidebarItemHelp.displayName = 'SidebarItemHelp'; diff --git a/electron/renderer/components/sidebar/sidebar-item-settings.tsx b/electron/renderer/components/sidebar/settings/sidebar-item-settings.tsx similarity index 77% rename from electron/renderer/components/sidebar/sidebar-item-settings.tsx rename to electron/renderer/components/sidebar/settings/sidebar-item-settings.tsx index 5f08845e..be8280ca 100644 --- a/electron/renderer/components/sidebar/sidebar-item-settings.tsx +++ b/electron/renderer/components/sidebar/settings/sidebar-item-settings.tsx @@ -5,3 +5,5 @@ export const SidebarItemSettings: React.FC = (): ReactNode => { // TODO return Settings; }; + +SidebarItemSettings.displayName = 'SidebarItemSettings'; diff --git a/electron/renderer/components/sidebar/sidebar-item-accounts.tsx b/electron/renderer/components/sidebar/sidebar-item-accounts.tsx deleted file mode 100644 index 3d660244..00000000 --- a/electron/renderer/components/sidebar/sidebar-item-accounts.tsx +++ /dev/null @@ -1,305 +0,0 @@ -import type { EuiBasicTableColumn } from '@elastic/eui'; -import { - EuiButton, - EuiButtonIcon, - EuiCallOut, - EuiConfirmModal, - EuiFieldPassword, - EuiFieldText, - EuiFlexGroup, - EuiFlexItem, - EuiForm, - EuiFormRow, - EuiInMemoryTable, - EuiPanel, - EuiSpacer, - EuiToolTip, -} from '@elastic/eui'; -import type { ReactNode } from 'react'; -import type React from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; -import { isBlank } from '../../../common/string/is-blank.js'; -import { runInBackground } from '../../lib/async/run-in-background.js'; - -interface TableRowItem { - accountName: string; -} - -interface FormRecord { - accountName?: string; - accountPassword?: string; -} - -export const SidebarItemAccounts: React.FC = (): ReactNode => { - const [showAddAccountModal, setShowAddAccountModal] = useState(false); - const [showEditAccountModal, setShowEditAccountModal] = useState(false); - const [showRemoveAccountModal, setShowRemoveAccountModal] = useState(false); - - const [tableRowItems, setTableRowItems] = useState>([]); - const [tableRowItem, setTableRowItem] = useState(); - - const { handleSubmit, control, reset } = useForm(); - - const loadAccounts = useCallback(async () => { - const accounts = await window.api.listAccounts(); - setTableRowItems(accounts); - }, []); - - const closeModals = useCallback(() => { - setShowAddAccountModal(false); - setShowEditAccountModal(false); - setShowRemoveAccountModal(false); - setTableRowItem(undefined); - reset({}); - }, [reset]); - - const onAddAccountClick = useCallback(() => { - closeModals(); - setShowAddAccountModal(true); - }, [closeModals]); - - const onEditAccountClick = useCallback( - (tableRowItem: TableRowItem) => { - closeModals(); - setTableRowItem(tableRowItem); - reset(tableRowItem); - setShowEditAccountModal(true); - }, - [setTableRowItem, reset, closeModals] - ); - - const onRemoveAccountClick = useCallback( - (tableRowItem: TableRowItem) => { - closeModals(); - setTableRowItem(tableRowItem); - reset(tableRowItem); - setShowRemoveAccountModal(true); - }, - [setTableRowItem, reset, closeModals] - ); - - const onAccountSaveConfirm = useCallback(() => { - runInBackground(async () => { - await handleSubmit(async (data: FormRecord) => { - await window.api.saveAccount({ - accountName: data.accountName!, - accountPassword: data.accountPassword!, - }); - await loadAccounts(); - closeModals(); - })(); - }); - }, [handleSubmit, loadAccounts, closeModals]); - - const onAccountRemoveConfirm = useCallback(() => { - runInBackground(async () => { - if (isBlank(tableRowItem?.accountName)) { - return; - } - await window.api.removeAccount({ - accountName: tableRowItem.accountName, - }); - await loadAccounts(); - closeModals(); - }); - }, [tableRowItem, loadAccounts, closeModals]); - - const accountAddModal = useMemo(() => { - return ( - - - - { - return ( - - ); - }} - /> - - - { - return ( - - ); - }} - /> - - - - ); - }, [control, onAccountSaveConfirm, closeModals]); - - const accountEditModal = useMemo(() => { - return ( - - - - { - return ( - - ); - }} - /> - - - { - return ( - - ); - }} - /> - - - - ); - }, [control, onAccountSaveConfirm, closeModals]); - - const accountRemoveModal = useMemo(() => { - return ( - Remove account {tableRowItem?.accountName}?} - onCancel={closeModals} - onConfirm={onAccountRemoveConfirm} - cancelButtonText="Cancel" - confirmButtonText="Remove" - buttonColor="danger" - defaultFocusedButton="cancel" - > - Associated characters will also be removed. - - ); - }, [tableRowItem, onAccountRemoveConfirm, closeModals]); - - useEffect(() => { - runInBackground(async () => { - await loadAccounts(); - }); - }, [loadAccounts]); - - const columns: Array> = [ - { - field: 'accountName', - name: 'Name', - dataType: 'string', - }, - { - field: 'actions', - name: 'Actions', - render: (_value: unknown, record: TableRowItem) => { - return ( - - - - onEditAccountClick(record)} - /> - - - - - onRemoveAccountClick(record)} - /> - - - - ); - }, - }, - ]; - - return ( - - - Securely add your DragonRealms accounts, then use the Characters menu to - add and play your characters. - - - - - onAddAccountClick()}> - Add Account - - - - - {tableRowItems.length > 0 && ( - - )} - - - - {showAddAccountModal && accountAddModal} - {showEditAccountModal && accountEditModal} - {showRemoveAccountModal && accountRemoveModal} - - ); -}; diff --git a/electron/renderer/components/sidebar/sidebar.tsx b/electron/renderer/components/sidebar/sidebar.tsx index 9ee7a976..d14c4bc4 100644 --- a/electron/renderer/components/sidebar/sidebar.tsx +++ b/electron/renderer/components/sidebar/sidebar.tsx @@ -1,10 +1,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiFlyout } from '@elastic/eui'; import type { ReactNode } from 'react'; import { useState } from 'react'; -import { SidebarItemAccounts } from './sidebar-item-accounts.jsx'; -import { SidebarItemCharacters } from './sidebar-item-characters.jsx'; -import { SidebarItemHelp } from './sidebar-item-help.jsx'; -import { SidebarItemSettings } from './sidebar-item-settings.jsx'; +import { SidebarItemAccounts } from './accounts/sidebar-item-accounts.jsx'; +import { SidebarItemCharacters } from './characters/sidebar-item-characters.jsx'; +import { SidebarItemHelp } from './help/sidebar-item-help.jsx'; +import { SidebarItemSettings } from './settings/sidebar-item-settings.jsx'; import { SidebarItem } from './sidebar-item.jsx'; export const Sidebar: React.FC = (): ReactNode => { @@ -76,6 +76,8 @@ export const Sidebar: React.FC = (): ReactNode => { type="overlay" paddingSize="s" size="s" + className="eui-yScroll" + hideCloseButton={true} outsideClickCloses={true} onClose={() => setShowCharacters(false)} > @@ -89,6 +91,8 @@ export const Sidebar: React.FC = (): ReactNode => { type="overlay" paddingSize="s" size="s" + className="eui-yScroll" + hideCloseButton={true} outsideClickCloses={true} onClose={() => setShowAccounts(false)} > @@ -102,6 +106,8 @@ export const Sidebar: React.FC = (): ReactNode => { type="overlay" paddingSize="s" size="s" + className="eui-yScroll" + hideCloseButton={true} outsideClickCloses={true} onClose={() => setShowSettings(false)} > diff --git a/electron/renderer/hooks/list-accounts.tsx b/electron/renderer/hooks/list-accounts.tsx new file mode 100644 index 00000000..6c241dd3 --- /dev/null +++ b/electron/renderer/hooks/list-accounts.tsx @@ -0,0 +1,24 @@ +import { sortBy } from 'lodash-es'; +import { useCallback, useEffect, useState } from 'react'; +import { runInBackground } from '../lib/async/run-in-background.js'; +import type { Account } from '../types/game.types.js'; + +export function useListAccounts(): Array { + const [accounts, setAccounts] = useState>([]); + + const loadAccounts = useCallback(async () => { + const allAccounts = await window.api.listAccounts(); + + const sortedAccounts = sortBy(allAccounts, 'accountName'); + + setAccounts(sortedAccounts); + }, []); + + useEffect(() => { + runInBackground(async () => { + await loadAccounts(); + }); + }, [loadAccounts]); + + return accounts; +} diff --git a/electron/renderer/hooks/list-characters.tsx b/electron/renderer/hooks/list-characters.tsx new file mode 100644 index 00000000..0889d9bf --- /dev/null +++ b/electron/renderer/hooks/list-characters.tsx @@ -0,0 +1,37 @@ +import { sortBy } from 'lodash-es'; +import { useEffect, useState } from 'react'; +import { isBlank } from '../../common/string/is-blank.js'; +import { runInBackground } from '../lib/async/run-in-background.js'; +import type { Character } from '../types/game.types.js'; + +export interface UseListCharactersProps { + accountName?: string; +} + +export function useListCharacters( + props?: UseListCharactersProps +): Array { + const { accountName } = props ?? {}; + + const [characters, setCharacters] = useState>([]); + + useEffect(() => { + runInBackground(async () => { + const allCharacters = await window.api.listCharacters(); + + const filteredCharacters = allCharacters.filter((character) => { + return isBlank(accountName) || character.accountName === accountName; + }); + + const sortedCharacters = sortBy( + filteredCharacters, + 'accountName', + 'characterName' + ); + + setCharacters(sortedCharacters); + }); + }, [accountName]); + + return characters; +} diff --git a/electron/renderer/types/game.types.ts b/electron/renderer/types/game.types.ts index 6b796d58..53486403 100644 --- a/electron/renderer/types/game.types.ts +++ b/electron/renderer/types/game.types.ts @@ -1,5 +1,15 @@ import type { SerializedStyles } from '@emotion/react'; +export interface Account { + accountName: string; +} + +export interface Character { + accountName: string; + characterName: string; + gameCode: string; +} + export interface GameLogLine { /** * A unique id for this log line.