Comprehensive review changes

This commit is contained in:
seavor 2026-04-20 18:58:40 -05:00
parent 3aa8c654cc
commit 6074d9d6e4
143 changed files with 2661 additions and 1535 deletions

View file

@ -100,7 +100,12 @@ describe('connection lifecycle', () => {
vi.advanceTimersByTime(5000);
// Fire onclose the way a real browser would when the connection-attempt
// timer closes a still-connecting socket.
mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent);
expect(mock.close).toHaveBeenCalled();
// Never-opened sockets bypass reconnect and land on DISCONNECTED directly.
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
});
@ -111,12 +116,15 @@ describe('connection lifecycle', () => {
const mock = getMockWebSocket();
getWebClient().disconnect();
// The transport schedules close() synchronously; onclose follows in the
// browser event loop. Simulate it so the status transition fires.
mock.onclose?.({ code: 1000, reason: '', wasClean: true } as CloseEvent);
expect(mock.close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
});
it('drops pending commands and clears state on unexpected socket close', () => {
it('enters RECONNECTING on unexpected socket close after a successful handshake', () => {
connectAndHandshake();
// A login command is now pending (sent during handshake)
@ -127,6 +135,8 @@ describe('connection lifecycle', () => {
mock.readyState = 3;
mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent);
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
// With reconnect configured, a drop after a successful open enters the
// reconnect state machine rather than going straight to DISCONNECTED.
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.RECONNECTING);
});
});

View file

@ -15,11 +15,20 @@ interface AppAuthRequestOverrides extends WebsocketTypes.AuthRequestMap {
ForgotPasswordResetParams: Omit<WebsocketTypes.PasswordResetConnectOptions, 'reason'>;
}
const CONNECTING_STATUS_LABEL = 'Connecting...';
function beginConnect(
options: { host: string; port: string | number },
reason: WebsocketTypes.WebSocketConnectReason,
): void {
setPendingOptions({ ...options, reason });
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, CONNECTING_STATUS_LABEL);
WebClient.instance.connect({ host: options.host, port: options.port });
}
export class AuthenticationRequestImpl implements WebsocketTypes.IAuthenticationRequest<AppAuthRequestOverrides> {
login(options: Omit<WebsocketTypes.LoginConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.LOGIN });
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
beginConnect(options, WebsocketTypes.WebSocketConnectReason.LOGIN);
}
testConnection(options: Omit<WebsocketTypes.TestConnectionOptions, 'reason'>): void {
@ -27,33 +36,23 @@ export class AuthenticationRequestImpl implements WebsocketTypes.IAuthentication
}
register(options: Omit<WebsocketTypes.RegisterConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.REGISTER });
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
beginConnect(options, WebsocketTypes.WebSocketConnectReason.REGISTER);
}
activateAccount(options: Omit<WebsocketTypes.ActivateConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT });
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
beginConnect(options, WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT);
}
resetPasswordRequest(options: Omit<WebsocketTypes.PasswordResetRequestConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST });
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST);
}
resetPasswordChallenge(options: Omit<WebsocketTypes.PasswordResetChallengeConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE });
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE);
}
resetPassword(options: Omit<WebsocketTypes.PasswordResetConnectOptions, 'reason'>): void {
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET });
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
WebClient.instance.connect({ host: options.host, port: options.port });
beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET);
}
disconnect(): void {

View file

@ -3,6 +3,7 @@
"language": "English",
"disconnect": "Disconnect",
"label": {
"confirmEmail": "Confirm Email",
"confirmPassword": "Confirm Password",
"confirmSure": "Are you sure?",
"country": "Country",
@ -19,6 +20,7 @@
"username": "Username"
},
"validation": {
"emailsMustMatch": "Emails don't match",
"minChars": "Minimum of {count} {count, plural, one {character} other {characters}} required",
"passwordsMustMatch": "Passwords don't match",
"required": "Required"

View file

@ -1,6 +1,3 @@
// eslint-disable-next-line
import React, { useMemo, useState } from 'react';
import { CardDTO } from '@app/services';
import './Card.css';
@ -10,11 +7,13 @@ interface CardProps {
}
const Card = ({ card }: CardProps) => {
const src = `https://api.scryfall.com/cards/${card?.identifiers?.scryfallId}?format=image`;
if (!card) {
return null;
}
return card && (
<img className="card" src={src} alt={card?.name} />
);
}
const src = `https://api.scryfall.com/cards/${card.identifiers?.scryfallId}?format=image`;
return <img className="card" src={src} alt={card.name} />;
};
export default Card;

View file

@ -30,7 +30,7 @@ const CardDetails = ({ card }: CardProps) => {
(!card.power && !card.toughness) ? null : (
<div className='cardDetails-attribute'>
<span className='cardDetails-attribute__label'>P/T:</span>
<span className='cardDetails-attribute__value'>{card.power || 0}/{card.toughness || 0}</span>
<span className='cardDetails-attribute__value'>{card.power ?? 0}/{card.toughness ?? 0}</span>
</div>
)
}

View file

@ -1,21 +1,28 @@
import React from 'react';
import Checkbox from '@mui/material/Checkbox';
import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
const CheckboxField = (props) => {
const { input: { value, onChange }, label, ...args } = props;
import type { FinalFormFieldProps } from '../fieldTypes';
type CheckboxFieldProps = FinalFormFieldProps<boolean, HTMLInputElement> & {
label?: string;
} & Omit<CheckboxProps, 'checked' | 'onChange' | 'onBlur' | 'onFocus' | 'name' | 'value'>;
const CheckboxField = ({ input, meta: _meta, label, ...args }: CheckboxFieldProps) => {
const { value, onChange, onBlur, onFocus, name } = input;
// @TODO this isnt unchecking properly
return (
<FormControlLabel
className="checkbox-field"
label={label}
label={label ?? ''}
control={
<Checkbox
{ ...args }
{...args}
className="checkbox-field__box"
checked={!!value}
onChange={(e, checked) => onChange(checked)}
name={name}
checked={Boolean(value)}
onChange={onChange}
onBlur={onBlur}
onFocus={onFocus}
color="primary"
/>
}

View file

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { Select, MenuItem } from '@mui/material';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
@ -8,49 +7,48 @@ import { useLocaleSort } from '@app/hooks';
import { Images } from '@app/images';
import { App } from '@app/types';
import type { FinalFormFieldProps } from '../fieldTypes';
import './CountryDropdown.css';
const CountryDropdown = ({ input: { onChange } }) => {
const [value, setValue] = useState('');
type CountryDropdownProps = FinalFormFieldProps<string, HTMLElement>;
const CountryDropdown = ({ input }: CountryDropdownProps) => {
const { t } = useTranslation();
const currentValue = (input.value as string | undefined) ?? '';
useEffect(() => onChange(value), [value]);
const translateCountry = country => t(`Common.countries.${country}`);
const translateCountry = (country: string) => t(`Common.countries.${country}`);
const sortedCountries = useLocaleSort(App.countryCodes, translateCountry);
return (
<FormControl size='small' variant='outlined' className='CountryDropdown'>
<InputLabel id='CountryDropdown-select'>Country</InputLabel>
<FormControl size="small" variant="outlined" className="CountryDropdown">
<InputLabel id="CountryDropdown-label">Country</InputLabel>
<Select
id='CountryDropdown-select'
labelId='CountryDropdown-label'
label='Country'
margin='dense'
value={value}
fullWidth={true}
onChange={e => setValue(e.target.value as string)}
id="CountryDropdown-select"
labelId="CountryDropdown-label"
label="Country"
margin="dense"
fullWidth
{...input}
value={currentValue}
>
<MenuItem value={''} key={-1}>
<MenuItem value="" key="none">
<div className="CountryDropdown-item">
<span className="CountryDropdown-item__label">None</span>
</div>
</MenuItem>
{
sortedCountries.map((country, index:number) => (
<MenuItem value={country} key={index}>
<div className="CountryDropdown-item">
<img className="CountryDropdown-item__image" src={Images.Countries[country.toLowerCase()]} />
<span className="CountryDropdown-item__label">{translateCountry(country)}</span>
</div>
</MenuItem>
))
}
{sortedCountries.map(country => (
<MenuItem value={country} key={country}>
<div className="CountryDropdown-item">
<img className="CountryDropdown-item__image" src={Images.Countries[country.toLowerCase()]} />
<span className="CountryDropdown-item__label">{translateCountry(country)}</span>
</div>
</MenuItem>
))}
</Select>
</FormControl>
)
);
};
export default CountryDropdown;

View file

@ -1,3 +1,5 @@
import { memo } from 'react';
import type { Data } from '@app/types';
import { cx } from '@app/utils';
@ -94,4 +96,4 @@ function CardSlot({
);
}
export default CardSlot;
export default memo(CardSlot);

View file

@ -37,7 +37,7 @@ function GameLog({ gameId }: GameLogProps) {
const name = players?.[m.playerId]?.properties.userInfo?.name ?? `p${m.playerId}`;
const lineClass = isEvent ? 'game-log__line game-log__line--event' : 'game-log__line';
return (
<div key={idx} className={lineClass}>
<div key={`${m.timeReceived}-${idx}`} className={lineClass}>
{!isEvent && <span className="game-log__author">{name}:</span>}
<span className="game-log__text">{m.message}</span>
</div>

View file

@ -3,6 +3,12 @@ import { useMemo, useState } from 'react';
import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
/**
* MTG turn phase count (0..10). Mirrors desktop's wrap-around behavior in
* `GameView::actNextPhase` see `types/game.ts` for the Phase enum.
*/
const PHASE_COUNT = 11;
export interface TurnControlsOpponent {
playerId: number;
name: string;
@ -119,11 +125,11 @@ export function useTurnControls({
if (!canAdvance || !hasLiveGame) {
return;
}
// Desktop wraps at 11 → 0 (the Phase enum is 010). When no phase is
// active yet (activePhase < 0 during the pre-game lobby), advance to
// Desktop wraps at PHASE_COUNT → 0 (the Phase enum is 010). When no phase
// is active yet (activePhase < 0 during the pre-game lobby), advance to
// Untap (0).
const current = game.activePhase;
const next = current >= 0 ? (current + 1) % 11 : 0;
const next = current >= 0 ? (current + 1) % PHASE_COUNT : 0;
webClient.request.game.setActivePhase(gameId, { phase: next });
};

View file

@ -1,4 +1,3 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { ServerSelectors, useAppSelector } from '@app/store';

View file

@ -1,4 +1,3 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { ServerSelectors, useAppSelector } from '@app/store';

View file

@ -12,7 +12,7 @@
.input-action__item {
width: 100%;
height: 100%;
height: 100%;
}
.input-action__item > div {
margin: 0;

View file

@ -1,12 +1,25 @@
import React from 'react';
import { Field } from 'react-final-form'
import { Field } from 'react-final-form';
import Button from '@mui/material/Button';
import { InputField } from '..';
import './InputAction.css';
const InputAction = ({ action, label, name, validate = () => false, disabled = false }) => (
interface InputActionProps {
action: string;
label: string;
name: string;
validate?: (value: unknown) => string | undefined | false;
disabled?: boolean;
}
const InputAction = ({
action,
label,
name,
validate = () => undefined,
disabled = false,
}: InputActionProps) => (
<div className="input-action">
<div className="input-action__item">
<Field label={label} name={name} component={InputField} validate={validate} />

View file

@ -1,57 +1,57 @@
import React from 'react';
import { styled } from '@mui/material/styles';
import TextField from '@mui/material/TextField';
import TextField, { TextFieldProps } from '@mui/material/TextField';
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
import type { FinalFormFieldProps } from '../fieldTypes';
import './InputField.css';
const PREFIX = 'InputField';
const classes = {
root: `${PREFIX}-root`
root: `${PREFIX}-root`,
};
const Root = styled('div')(({ theme }) => ({
[`&.${classes.root}`]: {
'& .InputField-error': {
color: theme.palette.error.main
color: theme.palette.error.main,
},
'& .InputField-warning': {
color: theme.palette.warning.main
color: theme.palette.warning.main,
},
},
}));
const InputField = ({ input, meta, ...args }) => {
type InputFieldProps =
FinalFormFieldProps<string, HTMLInputElement> &
Omit<TextFieldProps, 'value' | 'onChange' | 'onBlur' | 'onFocus' | 'name'>;
const InputField = ({ input, meta, ...args }: InputFieldProps) => {
const { touched, error, warning } = meta;
return (
<Root className={'InputField ' + classes.root}>
{ touched && (
<Root className={`InputField ${classes.root}`}>
{touched && (
<div className="InputField-validation">
{
(error &&
<div className="InputField-error">
{error}
<ErrorOutlinedIcon style={{ fontSize: 'small', fontWeight: 'bold' }} />
</div>
) ||
(warning && <div className="InputField-warning">{warning}</div>)
}
{(error &&
<div className="InputField-error">
{error}
<ErrorOutlinedIcon style={{ fontSize: 'small', fontWeight: 'bold' }} />
</div>
) || (warning && <div className="InputField-warning">{warning}</div>)}
</div>
) }
)}
<TextField
autoComplete='off'
{ ...input }
{ ...args }
autoComplete="off"
{...input}
{...args}
className="rounded"
variant="outlined"
margin="dense"
size="small"
fullWidth={true}
fullWidth
/>
</Root>
);

View file

@ -1,6 +1,6 @@
import { styled } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import { Select, MenuItem } from '@mui/material';
import { Select, MenuItem, SelectChangeEvent } from '@mui/material';
import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
import IconButton from '@mui/material/IconButton';
@ -14,8 +14,8 @@ import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
import { KnownHostDialog } from '@app/dialogs';
import { getHostPort, HostDTO } from '@app/services';
import Toast from '../Toast/Toast';
import type { FinalFormFieldProps } from '../fieldTypes';
import { TestConnection, useKnownHostsComponent } from './useKnownHostsComponent';
import './KnownHosts.css';
@ -50,9 +50,11 @@ const Root = styled('div')(({ theme }) => ({
},
}));
const KnownHosts = (props: any) => {
const { input, meta, disabled } = props;
const onChange: (value: HostDTO) => void = input.onChange;
type KnownHostsProps = FinalFormFieldProps<HostDTO | undefined, HTMLElement> & {
disabled?: boolean;
};
const KnownHosts = ({ input, meta, disabled }: KnownHostsProps) => {
const { touched, error, warning } = meta;
const { t } = useTranslation();
@ -61,22 +63,25 @@ const KnownHosts = (props: any) => {
selectedHost,
testingConnection,
dialogState,
showCreateToast,
showDeleteToast,
showEditToast,
setShowCreateToast,
setShowDeleteToast,
setShowEditToast,
onPick,
openAddKnownHostDialog,
openEditKnownHostDialog,
closeKnownHostDialog,
handleDialogRemove,
handleDialogSubmit,
} = useKnownHostsComponent({ onChange });
} = useKnownHostsComponent({ onChange: input.onChange });
const selectedId = selectedHost?.id ?? '';
const handleSelectChange = (event: SelectChangeEvent<number | ''>) => {
const value = event.target.value;
if (typeof value === 'number') {
void onPick(value);
}
};
return (
<Root className={'KnownHosts ' + classes.root}>
<Root className={`KnownHosts ${classes.root}`}>
<FormControl className="KnownHosts-form" size="small" variant="outlined">
{touched && (
<div className="KnownHosts-validation">
@ -97,24 +102,24 @@ const KnownHosts = (props: any) => {
label="Host"
margin="dense"
name="host"
value={selectedHost ?? ''}
fullWidth={true}
onChange={(e) => onPick(e.target.value as unknown as HostDTO)}
value={selectedId}
fullWidth
onChange={handleSelectChange}
disabled={disabled}
>
<Button value={selectedHost} onClick={openAddKnownHostDialog}>
<Button onClick={openAddKnownHostDialog}>
<span>{t('KnownHosts.add')}</span>
<AddIcon fontSize="small" color="primary" />
</Button>
{hosts.map((host, index) => {
{hosts.map((host) => {
const hostPort = getHostPort(host);
return (
<MenuItem value={host as any} key={host.id ?? index}>
<MenuItem value={host.id} key={host.id}>
<div className="KnownHosts-item">
<div className="KnownHosts-item__wrapper">
<div className={'KnownHosts-item__status ' + testingConnection}>
<div className={`KnownHosts-item__status ${testingConnection ?? ''}`}>
{testingConnection === TestConnection.FAILED ? (
<PortableWifiOffIcon fontSize="small" />
) : (
@ -151,20 +156,11 @@ const KnownHosts = (props: any) => {
<KnownHostDialog
isOpen={dialogState.open}
host={dialogState.edit}
host={dialogState.edit ?? undefined}
onRemove={handleDialogRemove}
onSubmit={handleDialogSubmit}
handleClose={closeKnownHostDialog}
/>
<Toast open={showCreateToast} onClose={() => setShowCreateToast(false)}>
{t('KnownHosts.toast', { mode: 'created' })}
</Toast>
<Toast open={showDeleteToast} onClose={() => setShowDeleteToast(false)}>
{t('KnownHosts.toast', { mode: 'deleted' })}
</Toast>
<Toast open={showEditToast} onClose={() => setShowEditToast(false)}>
{t('KnownHosts.toast', { mode: 'edited' })}
</Toast>
</Root>
);
};

View file

@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from '@app/components';
import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
import { getHostPort, HostDTO } from '@app/services';
import { ServerTypes } from '@app/store';
@ -16,13 +18,7 @@ export interface KnownHostsComponent {
selectedHost: App.Host | undefined;
testingConnection: TestConnection | null;
dialogState: { open: boolean; edit: HostDTO | null };
showCreateToast: boolean;
showDeleteToast: boolean;
showEditToast: boolean;
setShowCreateToast: (v: boolean) => void;
setShowDeleteToast: (v: boolean) => void;
setShowEditToast: (v: boolean) => void;
onPick: (host: HostDTO) => Promise<void>;
onPick: (id: number) => Promise<void>;
openAddKnownHostDialog: () => void;
openEditKnownHostDialog: (host: HostDTO) => void;
closeKnownHostDialog: () => void;
@ -39,11 +35,20 @@ export interface UseKnownHostsComponentArgs {
onChange: (value: HostDTO) => void;
}
type ToastMode = 'created' | 'deleted' | 'edited';
export function useKnownHostsComponent({
onChange,
}: UseKnownHostsComponentArgs): KnownHostsComponent {
const webClient = useWebClient();
const knownHosts = useKnownHosts();
const { t } = useTranslation();
const [toastMode, setToastMode] = useState<ToastMode>('created');
const knownHostToast = useToast({
key: 'known-hosts-action',
children: t('KnownHosts.toast', { mode: toastMode }),
});
const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({
open: false,
@ -51,16 +56,16 @@ export function useKnownHostsComponent({
});
const [testingConnection, setTestingConnection] = useState<TestConnection | null>(null);
const [showCreateToast, setShowCreateToast] = useState(false);
const [showDeleteToast, setShowDeleteToast] = useState(false);
const [showEditToast, setShowEditToast] = useState(false);
// Tracks the host currently awaiting a testConnection response. If null when a
// response arrives, the caller has moved on — ignore the stale reply.
const pendingTestRef = useRef<HostDTO | null>(null);
const selectedHost =
knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined;
const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : [];
const testConnection = (host: HostDTO) => {
pendingTestRef.current = host;
setTestingConnection(TestConnection.TESTING);
webClient.request.authentication.testConnection({ ...getHostPort(host) });
};
@ -73,28 +78,37 @@ export function useKnownHostsComponent({
testConnection(selectedHost);
}, [selectedHost]);
useReduxEffect(
() => {
setTestingConnection(TestConnection.SUCCESS);
},
ServerTypes.TEST_CONNECTION_SUCCESSFUL,
[],
);
useReduxEffect(() => {
if (!pendingTestRef.current) {
return;
}
setTestingConnection(TestConnection.SUCCESS);
pendingTestRef.current = null;
}, ServerTypes.TEST_CONNECTION_SUCCESSFUL, []);
useReduxEffect(
() => {
setTestingConnection(TestConnection.FAILED);
},
ServerTypes.TEST_CONNECTION_FAILED,
[],
);
useReduxEffect(() => {
if (!pendingTestRef.current) {
return;
}
setTestingConnection(TestConnection.FAILED);
pendingTestRef.current = null;
}, ServerTypes.TEST_CONNECTION_FAILED, []);
const onPick = async (host: HostDTO) => {
const fireToast = (mode: ToastMode) => {
setToastMode(mode);
knownHostToast.openToast();
};
const onPick = async (id: number) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
const host = knownHosts.value?.hosts.find((h) => h.id === id);
if (!host) {
return;
}
onChange(host);
await knownHosts.select(host.id!);
await knownHosts.select(id);
testConnection(host);
};
@ -116,7 +130,7 @@ export function useKnownHostsComponent({
}
await knownHosts.remove(id);
closeKnownHostDialog();
setShowDeleteToast(true);
fireToast('deleted');
};
const handleDialogSubmit = async ({
@ -136,11 +150,11 @@ export function useKnownHostsComponent({
if (id) {
await knownHosts.update(id, { name, host, port });
setShowEditToast(true);
fireToast('edited');
} else {
const newHost: App.Host = { name, host, port, editable: true };
await knownHosts.add(newHost);
setShowCreateToast(true);
fireToast('created');
}
closeKnownHostDialog();
@ -151,12 +165,6 @@ export function useKnownHostsComponent({
selectedHost,
testingConnection,
dialogState,
showCreateToast,
showDeleteToast,
showEditToast,
setShowCreateToast,
setShowDeleteToast,
setShowEditToast,
onPick,
openAddKnownHostDialog,
openEditKnownHostDialog,

View file

@ -1,6 +1,3 @@
.LanguageDropdown {
}
.LanguageDropdown-item {
display: flex;
align-items: center;

View file

@ -1,7 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Select, MenuItem } from '@mui/material';
import { Select, MenuItem, SelectChangeEvent } from '@mui/material';
import FormControl from '@mui/material/FormControl';
import { Images } from '@app/images';
@ -11,48 +9,43 @@ import './LanguageDropdown.css';
const LanguageDropdown = () => {
const { t, i18n } = useTranslation();
// i18next `resolvedLanguage` is undefined until a registered resource matches;
// MUI Select requires a concrete, in-range value.
const [language, setLanguage] = useState(i18n.resolvedLanguage ?? i18n.language ?? '');
const currentLanguage = i18n.resolvedLanguage ?? i18n.language ?? '';
useEffect(() => {
if (language !== i18n.resolvedLanguage) {
i18n.changeLanguage(language);
const onLanguageChange = (event: SelectChangeEvent) => {
const next = event.target.value as App.Language;
if (next !== currentLanguage) {
void i18n.changeLanguage(next);
}
}, [language]);
};
return (
<FormControl size='small' variant='outlined' className='LanguageDropdown'>
<FormControl size="small" variant="outlined" className="LanguageDropdown">
<Select
id='LanguageDropdown-select'
margin='dense'
value={language}
fullWidth={true}
onChange={e => setLanguage(e.target.value as App.Language)}
id="LanguageDropdown-select"
margin="dense"
value={currentLanguage}
fullWidth
onChange={onLanguageChange}
>
{
Object.keys(App.Language).map((lang) => {
const country = App.LanguageCountry[lang];
{Object.keys(App.Language).map((lang) => {
const country = App.LanguageCountry[lang];
const nativeName = App.LanguageNative[lang];
const translatedName = t(`Common.languages.${lang}`);
return (
<MenuItem value={lang} key={lang}>
<div className="LanguageDropdown-item">
<img className="LanguageDropdown-item__image" src={Images.Countries[country]} />
<span className="LanguageDropdown-item__label">
{App.LanguageNative[lang]} {
App.LanguageNative[lang] !== t(`Common.languages.${lang}`) && (
<>({ t(`Common.languages.${lang}`) })</>
)
}
</span>
</div>
</MenuItem>
);
})
}
return (
<MenuItem value={lang} key={lang}>
<div className="LanguageDropdown-item">
<img className="LanguageDropdown-item__image" src={Images.Countries[country]} />
<span className="LanguageDropdown-item__label">
{nativeName} {nativeName !== translatedName && <>({translatedName})</>}
</span>
</div>
</MenuItem>
);
})}
</Select>
</FormControl>
)
);
};
export default LanguageDropdown;

View file

@ -25,7 +25,11 @@ const Root = styled('span')(() => ({
}
}));
const CardCallout = ({ name }) => {
interface CardCalloutProps {
name: string;
}
const CardCallout = ({ name }: CardCalloutProps) => {
const { card, token, anchorEl, open, handlePopoverOpen, handlePopoverClose } =
useCardCallout(name);

View file

@ -7,7 +7,15 @@ import CardCallout from './CardCallout';
import { useParsedMessage } from './useMessage';
import './Message.css';
const Message = ({ message: { message } }) => (
interface MessagePayload {
message: string;
}
interface MessageProps {
message: MessagePayload;
}
const Message = ({ message: { message } }: MessageProps) => (
<div className='message'>
<div className='message__detail'>
<ParsedMessage message={message} />
@ -15,7 +23,11 @@ const Message = ({ message: { message } }) => (
</div>
);
const ParsedMessage = ({ message }) => {
interface ParsedMessageProps {
message: string;
}
const ParsedMessage = ({ message }: ParsedMessageProps) => {
const { name, chunks } = useParsedMessage(message, parseChunks);
return (
@ -26,7 +38,12 @@ const ParsedMessage = ({ message }) => {
);
};
const PlayerLink = ({ name, label = name }) => (
interface PlayerLinkProps {
name: string;
label?: string;
}
const PlayerLink = ({ name, label = name }: PlayerLinkProps) => (
<NavLink className="link" to={generatePath(App.RouteEnum.PLAYER, { name })}>
{label}
</NavLink>
@ -69,7 +86,7 @@ function parseMentionChunk(chunk: string): ReactNode {
if (mention) {
const name = mention[0].substr(1);
return (<PlayerLink name={name} label={mention} key={index} />);
return (<PlayerLink name={name} label={mention[0]} key={index} />);
}
return mentionChunk;

View file

@ -1,27 +1,25 @@
import { useEffect, useState, type ReactNode } from 'react';
import { useMemo, type ReactNode } from 'react';
import { App } from '@app/types';
export interface ParsedMessage {
name: string | null;
chunks: ReactNode[] | null;
chunks: ReactNode[];
}
export type ChunkParser = (chunk: string, index: number) => ReactNode;
// `parseChunk` must be a stable reference across renders (module-level function
// or `useCallback`). Passing a fresh closure every render will thrash the memo.
export function useParsedMessage(message: string, parseChunk: ChunkParser): ParsedMessage {
const [chunks, setChunks] = useState<ReactNode[] | null>(null);
const [name, setName] = useState<string | null>(null);
useEffect(() => {
return useMemo<ParsedMessage>(() => {
const match = message.match(App.MESSAGE_SENDER_REGEX);
if (match) {
setName(match[1]);
}
setChunks(parseMessage(message, parseChunk));
const name = match ? match[1] : null;
return {
name,
chunks: parseMessage(message, parseChunk),
};
}, [message, parseChunk]);
return { name, chunks };
}
export function parseMessage(message: string, parseChunk: ChunkParser): ReactNode[] {

View file

@ -1,24 +1,23 @@
import React, { useEffect, useRef } from 'react';
import { ReactNode, useEffect, useRef } from 'react';
const ScrollToBottomOnChanges = ({ content, changes }) => {
const messagesEndRef = useRef(null);
interface ScrollToBottomOnChangesProps {
content: ReactNode;
changes: unknown;
}
const scrollToBottom = () => {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
const ScrollToBottomOnChanges = ({ content, changes }: ScrollToBottomOnChangesProps) => {
const messagesEndRef = useRef<HTMLDivElement | null>(null);
useEffect(scrollToBottom, [changes]);
const styling = {
height: '100%'
};
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [changes]);
return (
<div style={styling}>
<div style={{ height: '100%' }}>
{content}
<div ref={messagesEndRef} />
</div>
)
}
);
};
export default ScrollToBottomOnChanges;

View file

@ -1,14 +1,29 @@
import React from 'react';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import type { FinalFormFieldProps } from '../fieldTypes';
import './SelectField.css';
const SelectField = ({ input, label, options, value }) => {
const id = label + '-select-field';
const labelId = id + '-label';
export interface SelectFieldOption<V extends string | number = string | number> {
value: V;
label: string;
}
interface SelectFieldProps<V extends string | number = string | number> extends FinalFormFieldProps<V, HTMLElement> {
label: string;
options: SelectFieldOption<V>[];
}
const SelectField = <V extends string | number = string | number>({
input,
label,
options,
}: SelectFieldProps<V>) => {
const id = `${label}-select-field`;
const labelId = `${id}-label`;
return (
<FormControl variant="outlined" margin="dense" className="select-field">
@ -16,13 +31,15 @@ const SelectField = ({ input, label, options, value }) => {
<Select
labelId={labelId}
id={id}
value={value}
{ ...input }
>{
options.map((option, index) => (
<MenuItem value={index} key={index}> { option } </MenuItem>
))
}</Select>
label={label}
{...input}
>
{options.map(option => (
<MenuItem value={option.value} key={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
);
};

View file

@ -12,7 +12,7 @@
.three-pane-layout .grid-main {
display: flex;
flex-direction: column;
flex-direction: column;
}
.three-pane-layout .grid-main__top {

View file

@ -1,35 +1,34 @@
import * as React from 'react'
import { createPortal } from 'react-dom'
import { ReactNode, SyntheticEvent } from 'react';
import Alert from '@mui/material/Alert';
import Alert, { AlertColor } from '@mui/material/Alert';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import Slide from '@mui/material/Slide';
import Slide, { SlideProps } from '@mui/material/Slide';
import Snackbar from '@mui/material/Snackbar';
const iconMapping = {
success: <CheckCircleIcon />
success: <CheckCircleIcon />,
};
export interface ToastProps {
open: boolean;
onClose: (event?: SyntheticEvent) => void;
severity?: AlertColor;
autoHideDuration?: number;
children?: ReactNode;
}
function Toast(props) {
const { open, onClose, severity = 'success', autoHideDuration = 10000, children } = props
const rootElemRef = React.useRef(document.createElement('div'));
React.useEffect(() => {
document.body.appendChild(rootElemRef.current)
return () => {
rootElemRef.current.remove();
}
}, [rootElemRef])
const handleClose = (event?: React.SyntheticEvent, reason?: string) => {
// MUI's Snackbar already self-portals to the end of document.body; adding our
// own createPortal wrapper would leak <div>s under React StrictMode's double-
// invoked effects. Render the Snackbar directly.
function Toast({ open, onClose, severity = 'success', autoHideDuration = 10000, children }: ToastProps) {
const handleClose = (event?: SyntheticEvent | Event, reason?: string) => {
if (reason === 'clickaway') {
return;
}
onClose(event);
onClose(event as SyntheticEvent | undefined);
};
const node = (
return (
<Snackbar
open={open}
autoHideDuration={autoHideDuration}
@ -37,23 +36,18 @@ function Toast(props) {
slots={{ transition: TransitionLeft }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert onClose={handleClose} severity={severity} iconMapping={iconMapping}
<Alert
onClose={handleClose}
severity={severity}
iconMapping={iconMapping}
slotProps={{ message: { children } }}
/>
</Snackbar>
)
if (!rootElemRef.current) {
return null
}
return createPortal(
node,
rootElemRef.current
);
}
function TransitionLeft(props) {
function TransitionLeft(props: SlideProps) {
return <Slide {...props} direction="left" />;
}
export default Toast
export default Toast;

View file

@ -1,71 +1,77 @@
import { createContext, FC, PropsWithChildren, ReactChild, ReactNode, useContext, useEffect, useReducer, Context } from 'react'
import { createContext, FC, PropsWithChildren, ReactNode, useContext, useEffect, useReducer } from 'react';
import { ACTIONS, initialState, reducer } from './reducer';
import Toast from './Toast'
import { ACTIONS, initialState, reducer, ToastEntry } from './reducer';
import Toast from './Toast';
interface ToastEntry {
isOpen: boolean,
children: ReactChild,
interface ToastContextValue {
toasts: Record<string, ToastEntry>;
addToast: (key: string, children: ReactNode) => void;
openToast: (key: string) => void;
closeToast: (key: string) => void;
removeToast: (key: string) => void;
}
interface ToastState {
toasts: Record<string, ToastEntry>,
addToast: (key, children) => void,
openToast: (key) => void,
closeToast: (key) => void,
removeToast: (key) => void,
}
const ToastContext: Context<any> = createContext<ToastState>({
const ToastContext = createContext<ToastContextValue>({
toasts: {},
addToast: (_key, _children) => {},
openToast: (_key) => {},
closeToast: (_key) => {},
removeToast: (_key) => {},
addToast: () => {},
openToast: () => {},
closeToast: () => {},
removeToast: () => {},
});
export const ToastProvider: FC<PropsWithChildren> = (props) => {
const { children } = props
const [state, dispatch] = useReducer(reducer, initialState)
const providerState = {
export const ToastProvider: FC<PropsWithChildren> = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
const providerState: ToastContextValue = {
toasts: state.toasts,
addToast: (key, children) => dispatch({ type: ACTIONS.ADD_TOAST, payload: { key, children } }),
openToast: key => dispatch({ type: ACTIONS.OPEN_TOAST, payload: { key } }),
closeToast: key => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } }),
removeToast: key => dispatch({ type: ACTIONS.REMOVE_TOAST, payload: { key } }),
}
addToast: (key, toastChildren) => dispatch({ type: ACTIONS.ADD_TOAST, payload: { key, children: toastChildren } }),
openToast: (key) => dispatch({ type: ACTIONS.OPEN_TOAST, payload: { key } }),
closeToast: (key) => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } }),
removeToast: (key) => dispatch({ type: ACTIONS.REMOVE_TOAST, payload: { key } }),
};
return (
<ToastContext.Provider value={providerState}>
{children}
<div>
{Object.entries(state.toasts).map(([key, value]: [string, ToastEntry]) => {
const { isOpen, children } = value;
return (
<Toast key={key} open={isOpen} onClose={() => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } })}>
{children}
</Toast>
)
})}
{Object.entries(state.toasts).map(([key, entry]) => (
<Toast
key={key}
open={entry.isOpen}
onClose={() => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } })}
>
{entry.children}
</Toast>
))}
</div>
</ToastContext.Provider>
)
}
);
};
export interface ToastHookOptions {
key: string,
children: ReactNode
key: string;
children: ReactNode;
}
export function useToast({ key, children }) {
const { addToast, openToast, closeToast, removeToast } = useContext(ToastContext)
export interface ToastHandle {
openToast: () => void;
closeToast: () => void;
removeToast: () => void;
}
export function useToast({ key, children }: ToastHookOptions): ToastHandle {
const { addToast, openToast, closeToast, removeToast } = useContext(ToastContext);
// Toast children are captured at registration; re-registering every render
// would churn provider state. Intentional mount/unmount-only effect keyed on `key`.
useEffect(() => {
addToast(key, children)
}, [])
addToast(key, children);
return () => {
removeToast(key);
};
}, [key]);
return {
openToast: () => openToast(key),
closeToast: () => closeToast(key),
removeToast: () => removeToast(key),
}
};
}

View file

@ -1,61 +1,88 @@
import type { ReactNode } from 'react';
export const ACTIONS = {
ADD_TOAST: 'ADD_TOAST',
OPEN_TOAST: 'OPEN_TOAST',
CLOSE_TOAST: 'CLOSE_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
export interface ToastEntry {
isOpen: boolean;
children: ReactNode;
// Refcount of active registrants for this key. Incremented on ADD, decremented on REMOVE.
// Prevents two mounted callers sharing a key from stomping each other's registration.
refs: number;
}
export const initialState = {
toasts: {}
export interface ToastState {
toasts: Record<string, ToastEntry>;
}
export function reducer(state, { type, payload }) {
const { key, children } = payload;
export const initialState: ToastState = {
toasts: {},
};
switch (type) {
export type ToastAction =
| { type: typeof ACTIONS.ADD_TOAST; payload: { key: string; children: ReactNode } }
| { type: typeof ACTIONS.OPEN_TOAST; payload: { key: string } }
| { type: typeof ACTIONS.CLOSE_TOAST; payload: { key: string } }
| { type: typeof ACTIONS.REMOVE_TOAST; payload: { key: string } };
export function reducer(state: ToastState, action: ToastAction): ToastState {
switch (action.type) {
case ACTIONS.ADD_TOAST: {
const { key, children } = action.payload;
const existing = state.toasts[key];
return {
...state,
toasts: {
...state.toasts,
[key]: {
isOpen: false,
children,
},
[key]: existing
? { ...existing, refs: existing.refs + 1 }
: { isOpen: false, children, refs: 1 },
},
};
}
case ACTIONS.OPEN_TOAST: {
const { key } = action.payload;
const existing = state.toasts[key];
if (!existing) {
return state;
}
return {
...state,
toasts: {
...state.toasts,
[key]: {
...state.toasts[key],
isOpen: true,
},
},
toasts: { ...state.toasts, [key]: { ...existing, isOpen: true } },
};
}
case ACTIONS.CLOSE_TOAST: {
const { key } = action.payload;
const existing = state.toasts[key];
if (!existing) {
return state;
}
return {
...state,
toasts: {
...state.toasts,
[key]: {
...state.toasts[key],
isOpen: false,
},
},
toasts: { ...state.toasts, [key]: { ...existing, isOpen: false } },
};
}
case ACTIONS.REMOVE_TOAST: {
const newState = { ...state };
delete newState.toasts[key];
return newState;
const { key } = action.payload;
const existing = state.toasts[key];
if (!existing) {
return state;
}
if (existing.refs > 1) {
return {
...state,
toasts: { ...state.toasts, [key]: { ...existing, refs: existing.refs - 1 } },
};
}
const nextToasts = { ...state.toasts };
delete nextToasts[key];
return { ...state, toasts: nextToasts };
}
default:
throw Error('Please pick an available action')
return state;
}
}

View file

@ -1,6 +1,3 @@
// eslint-disable-next-line
import React, { useMemo, useState } from 'react';
import { TokenDTO } from '@app/services';
import './Token.css';
@ -10,10 +7,11 @@ interface TokenProps {
}
const Token = ({ token }: TokenProps) => {
const set = Array.isArray(token?.set) ? token?.set[0] : token?.set;
return token && (
<img className="token" src={set?.picURL} alt={token?.name?.value} />
);
}
if (!token) {
return null;
}
const set = Array.isArray(token.set) ? token.set[0] : token.set;
return <img className="token" src={set?.picURL} alt={token.name?.value} />;
};
export default Token;

View file

@ -1,6 +1,3 @@
// eslint-disable-next-line
import React, { useMemo, useState } from 'react';
import { TokenDTO } from '@app/services';
import Token from '../Token/Token';
@ -21,7 +18,7 @@ const TokenDetails = ({ token }: TokenProps) => {
</div>
{
token && (
token && props && (
<div>
<div className='tokenDetails-attributes'>
<div className='tokenDetails-attribute'>
@ -29,52 +26,42 @@ const TokenDetails = ({ token }: TokenProps) => {
<span className='tokenDetails-attribute__value'>{token.name?.value}</span>
</div>
{
(!props.pt?.value) ? null : (
<div className='tokenDetails-attribute'>
<span className='tokenDetails-attribute__label'>P/T:</span>
<span className='tokenDetails-attribute__value'>{props.pt.value}</span>
</div>
)
}
{props.pt?.value && (
<div className='tokenDetails-attribute'>
<span className='tokenDetails-attribute__label'>P/T:</span>
<span className='tokenDetails-attribute__value'>{props.pt.value}</span>
</div>
)}
{
!props.colors?.value ? null : (
<div className='cardDetails-attribute'>
<span className='cardDetails-attribute__label'>Color(s):</span>
<span className='cardDetails-attribute__value'>{props.colors.value}</span>
</div>
)
}
{props.colors?.value && (
<div className='tokenDetails-attribute'>
<span className='tokenDetails-attribute__label'>Color(s):</span>
<span className='tokenDetails-attribute__value'>{props.colors.value}</span>
</div>
)}
{
!props.maintype?.value ? null : (
<div className='cardDetails-attribute'>
<span className='cardDetails-attribute__label'>Main Type:</span>
<span className='cardDetails-attribute__value'>{props.maintype.value}</span>
</div>
)
}
{props.maintype?.value && (
<div className='tokenDetails-attribute'>
<span className='tokenDetails-attribute__label'>Main Type:</span>
<span className='tokenDetails-attribute__value'>{props.maintype.value}</span>
</div>
)}
{
!props.type?.value ? null : (
<div className='cardDetails-attribute'>
<span className='cardDetails-attribute__label'>Type:</span>
<span className='cardDetails-attribute__value'>{props.type.value}</span>
</div>
)
}
{props.type?.value && (
<div className='tokenDetails-attribute'>
<span className='tokenDetails-attribute__label'>Type:</span>
<span className='tokenDetails-attribute__value'>{props.type.value}</span>
</div>
)}
</div>
{
!token.text?.value ? null : (
<div className='tokenDetails-text'>
<div className='tokenDetails-text__current'>
{token.text.value}
</div>
{token.text?.value && (
<div className='tokenDetails-text'>
<div className='tokenDetails-text__current'>
{token.text.value}
</div>
)
}
</div>
)}
</div>
)
}

View file

@ -32,7 +32,7 @@ const UserDisplay = ({ user }: UserDisplayProps) => {
<div className="user-display">
<NavLink to={generatePath(App.RouteEnum.PLAYER, { name })} className="plain-link">
<div className="user-display__details" onContextMenu={handleClick}>
<img className="user-display__country" src={Images.Countries[country]} alt={country}></img>
<img className="user-display__country" src={Images.Countries[country]} alt={country} />
<div className="user-display__name single-line-ellipsis">{name}</div>
</div>
</NavLink>

View file

@ -1,12 +1,16 @@
// eslint-disable-next-line
import React from "react";
import { ReactNode } from 'react';
import { List, RowComponentProps } from 'react-window';
import './VirtualList.css';
interface RowData {
items: any[];
items: ReactNode[];
}
interface VirtualListProps {
items: ReactNode[];
className?: string;
size?: number;
}
const Row = ({ index, style, items }: RowComponentProps<RowData>) => (
@ -15,7 +19,7 @@ const Row = ({ index, style, items }: RowComponentProps<RowData>) => (
</div>
);
const VirtualList = ({ items, className = '', size = 30 }) => (
const VirtualList = ({ items, className = '', size = 30 }: VirtualListProps) => (
<div className="virtual-list">
<List<RowData>
className={`virtual-list__list ${className}`}

View file

@ -0,0 +1,3 @@
import type { FieldRenderProps } from 'react-final-form';
export type FinalFormFieldProps<T, E extends HTMLElement = HTMLElement> = FieldRenderProps<T, E>;

View file

@ -1,3 +1,5 @@
export type { FinalFormFieldProps } from './fieldTypes';
// Common components
export { default as Card } from './Card/Card';
export { default as CardDetails } from './CardDetails/CardDetails';

View file

@ -7,8 +7,7 @@ import Paper from '@mui/material/Paper';
import { UserDisplay, VirtualList, AuthGuard, LanguageDropdown } from '@app/components';
import Layout from '../Layout/Layout';
import AddToBuddies from './AddToBuddies';
import AddToIgnore from './AddToIgnore';
import AddUserForm from './AddUserForm';
import { useAccount } from './useAccount';
import './Account.css';
@ -44,7 +43,7 @@ const Account = () => {
))}
/>
<div style={{ borderTop: '1px solid' }}>
<AddToBuddies onSubmit={handleAddToBuddies} />
<AddUserForm label="Add to Buddies" onSubmit={handleAddToBuddies} />
</div>
</Paper>
</div>
@ -61,7 +60,7 @@ const Account = () => {
))}
/>
<div style={{ borderTop: '1px solid' }}>
<AddToIgnore onSubmit={handleAddToIgnore} />
<AddUserForm label="Add to Ignore" onSubmit={handleAddToIgnore} />
</div>
</Paper>
</div>

View file

@ -1,16 +0,0 @@
import React from 'react';
import { Form } from 'react-final-form'
import { InputAction } from '@app/components';
const AddToBuddies = ({ onSubmit }) => (
<Form onSubmit={values => onSubmit(values)}>
{({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<InputAction action="Add" label="Add to Buddies" name="userName" />
</form>
)}
</Form>
);
export default AddToBuddies;

View file

@ -1,16 +0,0 @@
import React from 'react';
import { Form } from 'react-final-form'
import { InputAction } from '@app/components';
const AddToIgnore = ({ onSubmit }) => (
<Form onSubmit={values => onSubmit(values)}>
{({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<InputAction action="Add" label="Add to Ignore" name="userName" />
</form>
)}
</Form>
);
export default AddToIgnore;

View file

@ -0,0 +1,24 @@
import { Form } from 'react-final-form';
import { InputAction } from '@app/components';
interface AddUserFormValues {
userName: string;
}
interface AddUserFormProps {
label: string;
onSubmit: (values: AddUserFormValues) => void;
}
const AddUserForm = ({ label, onSubmit }: AddUserFormProps) => (
<Form<AddUserFormValues> onSubmit={(values) => onSubmit(values)}>
{({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<InputAction action="Add" label={label} name="userName" />
</form>
)}
</Form>
);
export default AddUserForm;

View file

@ -2,13 +2,14 @@ import { useEffect, useMemo } from 'react';
import { useWebClient } from '@app/hooks';
import { ServerSelectors, useAppSelector } from '@app/store';
import { Data } from '@app/types';
export interface Account {
buddyList: any[];
ignoreList: any[];
buddyList: Data.ServerInfo_User[];
ignoreList: Data.ServerInfo_User[];
serverName: string | undefined;
serverVersion: string | undefined;
user: any;
user: Data.ServerInfo_User | null;
avatarUrl: string;
handleAddToBuddies: (args: { userName: string }) => void;
handleAddToIgnore: (args: { userName: string }) => void;
@ -22,13 +23,13 @@ export function useAccount(): Account {
const serverVersion = useAppSelector((state) => ServerSelectors.getVersion(state));
const user = useAppSelector((state) => ServerSelectors.getUser(state));
const webClient = useWebClient();
const { avatarBmp } = user || {};
const avatarBmp = user?.avatarBmp;
const avatarUrl = useMemo(() => {
if (!avatarBmp) {
return '';
}
return URL.createObjectURL(new Blob([avatarBmp as BlobPart], { type: 'image/png' }));
return URL.createObjectURL(new Blob([avatarBmp], { type: 'image/png' }));
}, [avatarBmp]);
useEffect(() => {

View file

@ -8,23 +8,22 @@ import FeatureDetection from './FeatureDetection';
import './AppShell.css';
import { ToastProvider } from '@app/components'
import { ToastProvider } from '@app/components';
function AppShell() {
useEffect(() => {
window.onbeforeunload = () => true;
return () => {
window.onbeforeunload = null;
};
}, []);
const handleContextMenu = (event) => {
event.preventDefault();
};
return (
<Suspense fallback="loading">
<Provider store={store}>
<CssBaseline />
<ToastProvider>
<div className="AppShell" onContextMenu={handleContextMenu}>
<div className="AppShell">
<Router>
<FeatureDetection />
<Routes />

View file

@ -1,4 +1,3 @@
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { App } from '@app/types';

View file

@ -7,7 +7,7 @@ function Decks() {
return (
<Layout>
<AuthGuard />
<span>"Decks"</span>
<span>Decks</span>
</Layout>
);
}

View file

@ -1,12 +1,21 @@
import { ReactNode } from 'react';
import LeftNav from './LeftNav';
import './Layout.css'
function Layout(props:LayoutProps) {
interface LayoutProps {
showNav?: boolean;
children: ReactNode;
className?: string;
noHeightLimit?: boolean;
}
function Layout(props: LayoutProps) {
const { children, className, showNav = true, noHeightLimit = false } = props;
const containerClasses = ['layout']
if (noHeightLimit === true) {
containerClasses.push('layout--no-height-limit')
const containerClasses = ['layout'];
if (noHeightLimit) {
containerClasses.push('layout--no-height-limit');
}
return (
@ -29,11 +38,4 @@ function BottomBar() {
)
}
interface LayoutProps {
showNav?: boolean;
children: any;
className?: string;
noHeightLimit?: boolean
}
export default Layout;

View file

@ -38,9 +38,6 @@
margin-left: 10px;
}
.LeftNav-nav {
}
.LeftNav-nav__links {
display: flex;
flex-flow: column;
@ -102,27 +99,7 @@
justify-content: center;
}
.LeftNav-nav__action {
}
.LeftNav-nav__action button {
color: white;
}
.temp-subnav__rooms {
display: flex;
align-items: center;
font-size: 10px;
padding: 5px;
}
.temp-chip {
margin-left: 5px;
text-decoration: none;
}
.temp-chip > div {
cursor: inherit;
}

View file

@ -109,8 +109,8 @@ const LeftNav = () => {
}}
>
{state.options.map((option) => (
<MenuItem key={option} onClick={() => handleMenuItemClick(option)}>
{option}
<MenuItem key={option.label} onClick={() => handleMenuItemClick(option)}>
{option.label}
</MenuItem>
))}

View file

@ -1,68 +1,69 @@
import { useEffect, useState } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate, generatePath } from 'react-router-dom';
import { useWebClient } from '@app/hooks';
import { RoomsSelectors, ServerSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
export interface LeftNavOption {
label: string;
route: App.RouteEnum;
}
interface LeftNavState {
anchorEl: Element | null;
showCardImportDialog: boolean;
options: string[];
options: LeftNavOption[];
}
export interface LeftNav {
joinedRooms: any[];
joinedRooms: ReturnType<typeof RoomsSelectors.getJoinedRooms>;
isConnected: boolean;
state: LeftNavState;
handleMenuOpen: (event: React.MouseEvent) => void;
handleMenuItemClick: (option: string) => void;
handleMenuItemClick: (option: LeftNavOption) => void;
handleMenuClose: () => void;
leaveRoom: (event: React.MouseEvent, roomId: number) => void;
openImportCardWizard: () => void;
closeImportCardWizard: () => void;
}
const BASE_OPTIONS: LeftNavOption[] = [
{ label: 'Account', route: App.RouteEnum.ACCOUNT },
{ label: 'Replays', route: App.RouteEnum.REPLAYS },
];
const MODERATOR_OPTIONS: LeftNavOption[] = [
{ label: 'Administration', route: App.RouteEnum.ADMINISTRATION },
{ label: 'Logs', route: App.RouteEnum.LOGS },
];
export function useLeftNav(): LeftNav {
const joinedRooms = useAppSelector((state) => RoomsSelectors.getJoinedRooms(state));
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
const isModerator = useAppSelector(ServerSelectors.getIsUserModerator);
const navigate = useNavigate();
const webClient = useWebClient();
const [state, setState] = useState<LeftNavState>({
anchorEl: null,
showCardImportDialog: false,
options: [],
});
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
const [showCardImportDialog, setShowCardImportDialog] = useState(false);
useEffect(() => {
let options: string[] = [
'Account',
'Replays',
];
const options = useMemo<LeftNavOption[]>(
() => (isModerator ? [...BASE_OPTIONS, ...MODERATOR_OPTIONS] : BASE_OPTIONS),
[isModerator],
);
if (isModerator) {
options = [
...options,
'Administration',
'Logs',
];
}
setState((s) => ({ ...s, options }));
}, [isModerator]);
const state: LeftNavState = { anchorEl, showCardImportDialog, options };
const handleMenuOpen = (event: React.MouseEvent) => {
setState((s) => ({ ...s, anchorEl: event.target as Element }));
setAnchorEl(event.target as Element);
};
const handleMenuItemClick = (option: string) => {
const route = App.RouteEnum[option.toUpperCase()];
navigate(generatePath(route));
const handleMenuItemClick = (option: LeftNavOption) => {
navigate(generatePath(option.route));
};
const handleMenuClose = () => {
setState((s) => ({ ...s, anchorEl: null }));
setAnchorEl(null);
};
const leaveRoom = (event: React.MouseEvent, roomId: number) => {
@ -71,12 +72,12 @@ export function useLeftNav(): LeftNav {
};
const openImportCardWizard = () => {
setState((s) => ({ ...s, showCardImportDialog: true }));
setShowCardImportDialog(true);
handleMenuClose();
};
const closeImportCardWizard = () => {
setState((s) => ({ ...s, showCardImportDialog: false }));
setShowCardImportDialog(false);
};
return {

View file

@ -54,7 +54,7 @@
overflow: hidden;
}
.login-content__description-wrapper {
.login-content__description-wrapper {
position: relative;
width: 70%;
display: flex;
@ -115,8 +115,8 @@
margin: 40px 0 20px;
font-size: 28px;
font-weight: bold;
}
.login-content__description-subtitle2 {
font-size: 14px;
}

View file

@ -2,6 +2,12 @@ import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from '@app/components';
import type {
LoginFormValues,
RegisterFormValues,
RequestPasswordResetFormValues,
ResetPasswordFormValues,
} from '@app/forms';
import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
import { getHostPort } from '@app/services';
import { ServerSelectors, ServerTypes, useAppSelector } from '@app/store';
@ -17,16 +23,15 @@ export interface LoginDialogState {
export interface Login {
description: string | undefined;
isConnected: boolean;
pendingActivationOptions: WebsocketTypes.PendingActivationContext | null;
dialogState: LoginDialogState;
userToResetPassword: string | null;
submitButtonDisabled: boolean;
handleLogin: (form: any) => void;
handleLogin: (form: LoginFormValues) => void;
showDescription: () => boolean;
handleRegistrationDialogSubmit: (form: any) => void;
handleRegistrationDialogSubmit: (form: RegisterFormValues) => void;
handleAccountActivationDialogSubmit: (args: { token: string }) => void;
handleRequestPasswordResetDialogSubmit: (form: any) => void;
handleResetPasswordDialogSubmit: (args: any) => void;
handleRequestPasswordResetDialogSubmit: (form: RequestPasswordResetFormValues) => void;
handleResetPasswordDialogSubmit: (form: ResetPasswordFormValues) => void;
skipTokenRequest: (userName: string) => void;
closeRequestPasswordResetDialog: () => void;
openRequestPasswordResetDialog: () => void;
@ -46,7 +51,7 @@ export function useLogin(): Login {
const [pendingActivationOptions, setPendingActivationOptions] =
useState<WebsocketTypes.PendingActivationContext | null>(null);
const rememberLoginRef = useRef<any>(null);
const rememberLoginRef = useRef<LoginFormValues | RegisterFormValues | null>(null);
const knownHosts = useKnownHosts();
const [dialogState, setDialogState] = useState<LoginDialogState>({
passwordResetRequestDialog: false,
@ -113,13 +118,13 @@ export function useLogin(): Login {
setPendingActivationOptions(null);
}, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []);
useReduxEffect(({ payload: { options } }) => {
useReduxEffect<{ options: WebsocketTypes.PendingActivationContext }>(({ payload: { options } }) => {
setPendingActivationOptions(options);
closeRegistrationDialog();
openActivateAccountDialog();
}, ServerTypes.ACCOUNT_AWAITING_ACTIVATION, []);
const onSubmitLogin = useCallback((loginForm) => {
const onSubmitLogin = useCallback((loginForm: LoginFormValues) => {
rememberLoginRef.current = loginForm;
const { userName, password, selectedHost, remember } = loginForm;
@ -134,7 +139,7 @@ export function useLogin(): Login {
}
webClient.request.authentication.login(options);
}, []);
}, [webClient]);
const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin);
@ -142,7 +147,13 @@ export function useLogin(): Login {
resetSubmitButton();
}, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []);
const updateHost = (hashedPassword: string, { selectedHost, remember, userName }: any) => {
const updateHost = (
hashedPassword: string,
{ selectedHost, remember, userName }: LoginFormValues,
) => {
if (selectedHost.id == null) {
return;
}
knownHosts.update(selectedHost.id, {
remember,
userName: remember ? userName : null,
@ -150,9 +161,10 @@ export function useLogin(): Login {
});
};
useReduxEffect(({ payload: { options: { hashedPassword } } }) => {
if (rememberLoginRef.current) {
updateHost(hashedPassword, rememberLoginRef.current);
useReduxEffect<{ options: WebsocketTypes.LoginSuccessContext }>(({ payload: { options } }) => {
const loginForm = rememberLoginRef.current;
if (loginForm && 'remember' in loginForm) {
updateHost(options.hashedPassword, loginForm);
}
}, ServerTypes.LOGIN_SUCCESSFUL, []);
@ -162,7 +174,7 @@ export function useLogin(): Login {
return Boolean(!isConnected && description?.length);
};
const handleRegistrationDialogSubmit = (registerForm: any) => {
const handleRegistrationDialogSubmit = (registerForm: RegisterFormValues) => {
rememberLoginRef.current = registerForm;
const { userName, password, email, country, realName, selectedHost } = registerForm;
@ -188,7 +200,7 @@ export function useLogin(): Login {
});
};
const handleRequestPasswordResetDialogSubmit = (form: any) => {
const handleRequestPasswordResetDialogSubmit = (form: RequestPasswordResetFormValues) => {
const { userName, email, selectedHost } = form;
const { host, port } = getHostPort(selectedHost);
@ -200,7 +212,12 @@ export function useLogin(): Login {
}
};
const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }: any) => {
const handleResetPasswordDialogSubmit = ({
userName,
token,
newPassword,
selectedHost,
}: ResetPasswordFormValues) => {
const { host, port } = getHostPort(selectedHost);
webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port });
};
@ -218,7 +235,6 @@ export function useLogin(): Login {
return {
description,
isConnected,
pendingActivationOptions,
dialogState,
userToResetPassword,
submitButtonDisabled,

View file

@ -1,3 +1,6 @@
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
@ -8,37 +11,99 @@ import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import Typography from '@mui/material/Typography';
import type { Data } from '@app/types';
import type { ServerStateLogs } from '@app/store';
import { useLogResults } from './useLogResults';
import './LogResults.css';
const LogResults = (props) => {
const { logs } = props;
interface LogResultsProps {
logs: ServerStateLogs;
}
const hasRoomLogs = logs.room && logs.room.length;
const hasGameLogs = logs.game && logs.game.length;
const hasChatLogs = logs.chat && logs.chat.length;
interface HeaderCell {
label: string;
}
interface ResultsProps {
headerCells: HeaderCell[];
logs: Data.ServerInfo_ChatMessage[];
}
interface TabPanelProps {
children?: ReactNode;
value: number;
index: number;
}
const a11yProps = (index: number): { id: string; 'aria-controls': string } => ({
id: `logs-tab-${index}`,
'aria-controls': `logs-tabpanel-${index}`,
});
const TabPanel = ({ children, value, index }: TabPanelProps) => (
<div
role="tabpanel"
hidden={value !== index}
id={`logs-tabpanel-${index}`}
aria-labelledby={`logs-tab-${index}`}
>
<Box>{children}</Box>
</div>
);
const Results = ({ headerCells, logs }: ResultsProps) => (
<Paper className="log-results">
<Table size="small">
<TableHead>
<TableRow>
{headerCells.map(({ label }) => (
<TableCell key={label}>{label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{logs.map((log, index) => (
<TableRow key={`${log.time}-${log.senderIp}-${index}`}>
<TableCell>{log.time}</TableCell>
<TableCell>{log.senderName}</TableCell>
<TableCell>{log.senderIp}</TableCell>
<TableCell>{log.message}</TableCell>
<TableCell>{log.targetId}</TableCell>
<TableCell>{log.targetName}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
);
const LogResults = ({ logs }: LogResultsProps) => {
const { t } = useTranslation();
const { value, handleChange } = useLogResults();
const headerCells = [
{ label: 'Time' },
{ label: 'Sender Name' },
{ label: 'Sender IP' },
{ label: 'Message' },
{ label: 'Target ID' },
{ label: 'Target Name' },
const headerCells: HeaderCell[] = [
{ label: t('Logs.column.time') },
{ label: t('Logs.column.senderName') },
{ label: t('Logs.column.senderIp') },
{ label: t('Logs.column.message') },
{ label: t('Logs.column.targetId') },
{ label: t('Logs.column.targetName') },
];
const roomCount = logs.room?.length ?? 0;
const gameCount = logs.game?.length ?? 0;
const chatCount = logs.chat?.length ?? 0;
return (
<div>
<AppBar position="static">
<Tabs value={value} onChange={handleChange} aria-label="simple tabs example">
<Tab label={'Rooms' + (hasRoomLogs ? ` [${logs.room.length}]` : '')} {...a11yProps(0)} />
<Tab label={'Games' + (hasGameLogs ? ` [${logs.game.length}]` : '')} {...a11yProps(1)} />
<Tab label={'Chats' + (hasChatLogs ? ` [${logs.chat.length}]` : '')} {...a11yProps(2)} />
<Tabs value={value} onChange={handleChange} aria-label={t('Logs.title', { defaultValue: 'Log Results' })}>
<Tab label={`${t('Logs.tab.rooms')}${roomCount > 0 ? ` [${roomCount}]` : ''}`} {...a11yProps(0)} />
<Tab label={`${t('Logs.tab.games')}${gameCount > 0 ? ` [${gameCount}]` : ''}`} {...a11yProps(1)} />
<Tab label={`${t('Logs.tab.chats')}${chatCount > 0 ? ` [${chatCount}]` : ''}`} {...a11yProps(2)} />
</Tabs>
</AppBar>
<TabPanel value={value} index={0}>
@ -54,52 +119,4 @@ const LogResults = (props) => {
);
};
const a11yProps = index => {
return {
id: `simple-tab-${index}`,
'aria-controls': `simple-tabpanel-${index}`,
};
};
const TabPanel = ({ children, value, index, ...other }) => {
return (
<Typography
component="div"
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
>
<Box>{children}</Box>
</Typography>
);
};
const Results = ({ headerCells, logs }) => (
<Paper className="log-results">
<Table size="small">
<TableHead>
<TableRow>
{headerCells.map(({ label }) => (
<TableCell key={label}>{label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{logs.map(({ time, senderName, senderIp, message, targetId, targetName }, index) => (
<TableRow key={index}>
<TableCell>{time}</TableCell>
<TableCell>{senderName}</TableCell>
<TableCell>{senderIp}</TableCell>
<TableCell>{message}</TableCell>
<TableCell>{targetId}</TableCell>
<TableCell>{targetName}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
);
export default LogResults;

View file

@ -0,0 +1,20 @@
{
"Logs": {
"tab": {
"rooms": "Rooms",
"games": "Games",
"chats": "Chats"
},
"column": {
"time": "Time",
"senderName": "Sender Name",
"senderIp": "Sender IP",
"message": "Message",
"targetId": "Target ID",
"targetName": "Target Name"
},
"message": {
"emptyFilter": "Enter at least one search field before submitting."
}
}
}

View file

@ -1,19 +1,26 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from '@app/components';
import { useWebClient } from '@app/hooks';
import { ServerDispatch, ServerSelectors, useAppSelector } from '@app/store';
import { ServerDispatch, ServerSelectors, ServerStateLogs, useAppSelector } from '@app/store';
import { Data } from '@app/types';
const MAXIMUM_RESULTS = 1000;
export interface Logs {
logs: any;
logs: ServerStateLogs;
onSubmit: (fields: Data.ViewLogHistoryParams) => void;
}
export function useLogs(): Logs {
const { t } = useTranslation();
const logs = useAppSelector((state) => ServerSelectors.getLogs(state));
const webClient = useWebClient();
const { openToast } = useToast({
key: 'logs-empty-filter',
children: t('Logs.message.emptyFilter'),
});
useEffect(() => {
return () => {
@ -21,26 +28,29 @@ export function useLogs(): Logs {
};
}, []);
const trimFields = (fields: any) => {
const result: any = {};
for (const [key, field] of Object.entries(fields)) {
const trimFields = (fields: Data.ViewLogHistoryParams): Data.ViewLogHistoryParams => {
const result: Data.ViewLogHistoryParams = { ...fields };
for (const key of Object.keys(result) as (keyof Data.ViewLogHistoryParams)[]) {
const field = result[key];
if (typeof field === 'string') {
const trimmed = field.trim();
if (trimmed) {
result[key] = trimmed;
(result as Record<string, unknown>)[key] = trimmed;
} else {
delete (result as Record<string, unknown>)[key];
}
} else {
result[key] = field;
}
}
return result;
};
const flattenLogLocations = (logLocations: any) => Object.keys(logLocations);
const flattenLogLocations = (logLocations: Record<string, unknown>): string[] =>
Object.keys(logLocations);
const onSubmit = (fields: Data.ViewLogHistoryParams) => {
const trimmedFields: any = trimFields(fields);
const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields;
const trimmedFields = trimFields(fields);
const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields as
Data.ViewLogHistoryParams & { logLocation?: Record<string, unknown> };
const required = [userName, ipAddress, gameName, gameId, message].filter(Boolean);
@ -53,7 +63,7 @@ export function useLogs(): Logs {
if (required.length) {
webClient.request.moderator.viewLogHistory(trimmedFields);
} else {
// @TODO use yet-to-be-implemented banner/alert
openToast();
}
};

View file

@ -0,0 +1,66 @@
.player-view {
display: flex;
justify-content: center;
padding: 24px;
}
.player-view__card {
width: 100%;
max-width: 640px;
padding: 24px;
}
.player-view__avatar-wrapper {
display: flex;
justify-content: center;
margin-bottom: 16px;
}
.player-view__avatar {
width: 160px;
height: 160px;
border-radius: 8px;
object-fit: cover;
background-color: var(--bg-subtle, #eee);
}
.player-view__name {
text-align: center;
margin: 0 0 8px;
}
.player-view__level-badge {
text-align: center;
margin-bottom: 16px;
color: var(--text-muted, #666);
}
.player-view__details {
display: grid;
grid-template-columns: max-content 1fr;
gap: 8px 16px;
margin-bottom: 16px;
}
.player-view__label {
font-weight: bold;
}
.player-view__country-flag {
display: inline-block;
vertical-align: middle;
margin-right: 8px;
height: 16px;
}
.player-view__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.player-view__empty {
text-align: center;
color: var(--text-muted, #666);
}

View file

@ -0,0 +1,33 @@
{
"Player": {
"title": "User Information",
"label": {
"realName": "Real Name",
"location": "Location",
"userLevel": "User Level",
"accountAge": "Account Age"
},
"level": {
"administrator": "Administrator",
"moderator": "Moderator",
"registered": "Registered user",
"unregistered": "Unregistered user",
"judge": "Judge"
},
"age": {
"unknown": "Unknown",
"days": "{count, plural, one {# day} other {# days}}",
"daysWithYears": "{years, plural, one {# year} other {# years}}, {days, plural, one {# day} other {# days}}"
},
"action": {
"addBuddy": "Add to Buddy List",
"removeBuddy": "Remove from Buddy List",
"addIgnore": "Add to Ignore List",
"removeIgnore": "Remove from Ignore List",
"message": "Message",
"warn": "Warn User",
"ban": "Ban from Server",
"notFound": "User not found or still loading…"
}
}
}

View file

@ -1,14 +1,177 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Layout from '../Layout/Layout';
import { AuthGuard } from '@app/components';
import { Images } from '@app/images';
import { Data } from '@app/types';
import { usePlayer } from './usePlayer';
import './Player.css';
const AVATAR_DATA_URI_PREFIX = 'data:image/png;base64,';
/** Builds a data URI from a raw avatar payload, or returns null when no avatar is set. */
function avatarSrc(bmp: Uint8Array | undefined): string | null {
if (!bmp || bmp.byteLength === 0) {
return null;
}
let binary = '';
for (let i = 0; i < bmp.byteLength; i += 1) {
binary += String.fromCharCode(bmp[i]);
}
return AVATAR_DATA_URI_PREFIX + btoa(binary);
}
/** Matches desktop UserInfoBox level hierarchy: admin > moderator > registered > unregistered, plus Judge marker. */
function userLevelLabel(userLevel: number, t: (k: string) => string): string {
const Flag = Data.ServerInfo_User_UserLevelFlag;
const parts: string[] = [];
if ((userLevel & Flag.IsAdmin) === Flag.IsAdmin) {
parts.push(t('Player.level.administrator'));
} else if ((userLevel & Flag.IsModerator) === Flag.IsModerator) {
parts.push(t('Player.level.moderator'));
} else if ((userLevel & Flag.IsRegistered) === Flag.IsRegistered) {
parts.push(t('Player.level.registered'));
} else {
parts.push(t('Player.level.unregistered'));
}
if ((userLevel & Flag.IsJudge) === Flag.IsJudge) {
parts.push(t('Player.level.judge'));
}
return parts.join(' | ');
}
/** Formats account age like desktop's getAgeString: "Unknown" | "N days" | "Y years, D days". */
function formatAccountAge(
accountageSecs: bigint | undefined,
userLevel: number,
t: (k: string, params?: Record<string, unknown>) => string,
): string {
const Flag = Data.ServerInfo_User_UserLevelFlag;
const isRegistered =
(userLevel & Flag.IsAdmin) === Flag.IsAdmin ||
(userLevel & Flag.IsModerator) === Flag.IsModerator ||
(userLevel & Flag.IsRegistered) === Flag.IsRegistered;
if (!isRegistered) {
return t('Player.level.unregistered');
}
if (!accountageSecs || accountageSecs <= 0n) {
return t('Player.age.unknown');
}
const totalDays = Number(accountageSecs / 86400n);
const years = Math.floor(totalDays / 365);
const days = totalDays - years * 365;
if (years > 0) {
return t('Player.age.daysWithYears', { years, days });
}
return t('Player.age.days', { count: days });
}
const Player = () => {
const { t } = useTranslation();
const {
name,
userInfo,
isSelf,
isABuddy,
isIgnored,
isModerator,
onAddBuddy,
onRemoveBuddy,
onAddIgnore,
onRemoveIgnore,
onSendMessage,
onWarnUser,
onBanFromServer,
} = usePlayer();
const avatar = useMemo(() => avatarSrc(userInfo?.avatarBmp), [userInfo?.avatarBmp]);
const countryCode = userInfo?.country?.toUpperCase() ?? '';
function Player() {
return (
<Layout>
<AuthGuard />
<span>"Player"</span>
<div className="player-view">
<Paper className="player-view__card" elevation={2}>
<Typography variant="h5" className="player-view__name">
{t('Player.title')}
</Typography>
{!userInfo && (
<Typography className="player-view__empty">{t('Player.action.notFound')}</Typography>
)}
{userInfo && (
<>
<div className="player-view__avatar-wrapper">
{avatar
? <img className="player-view__avatar" src={avatar} alt={name ?? ''} />
: <div className="player-view__avatar" aria-hidden="true" />}
</div>
<Typography variant="h6" className="player-view__name">{userInfo.name}</Typography>
<Typography className="player-view__level-badge">
{userLevelLabel(userInfo.userLevel, t)}
{userInfo.privlevel && userInfo.privlevel !== 'NONE' ? ` | ${userInfo.privlevel}` : ''}
</Typography>
<div className="player-view__details">
<span className="player-view__label">{t('Player.label.realName')}</span>
<span>{userInfo.realName || '—'}</span>
<span className="player-view__label">{t('Player.label.location')}</span>
<span>
{countryCode && (
<img
className="player-view__country-flag"
src={Images.Countries[userInfo.country]}
alt={countryCode}
/>
)}
{countryCode || '—'}
</span>
<span className="player-view__label">{t('Player.label.userLevel')}</span>
<span>{userLevelLabel(userInfo.userLevel, t)}</span>
<span className="player-view__label">{t('Player.label.accountAge')}</span>
<span>{formatAccountAge(userInfo.accountageSecs, userInfo.userLevel, t)}</span>
</div>
{!isSelf && (
<div className="player-view__actions">
<Button variant="outlined" onClick={isABuddy ? onRemoveBuddy : onAddBuddy}>
{isABuddy ? t('Player.action.removeBuddy') : t('Player.action.addBuddy')}
</Button>
<Button variant="outlined" onClick={isIgnored ? onRemoveIgnore : onAddIgnore}>
{isIgnored ? t('Player.action.removeIgnore') : t('Player.action.addIgnore')}
</Button>
<Button variant="outlined" onClick={() => onSendMessage('')}>
{t('Player.action.message')}
</Button>
{isModerator && (
<>
<Button variant="outlined" color="warning" onClick={() => onWarnUser('')}>
{t('Player.action.warn')}
</Button>
<Button variant="outlined" color="error" onClick={() => onBanFromServer(0, '', '')}>
{t('Player.action.ban')}
</Button>
</>
)}
</div>
)}
</>
)}
</Paper>
</div>
</Layout>
);
}
};
export default Player;

View file

@ -0,0 +1,84 @@
import { useEffect, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useWebClient } from '@app/hooks';
import { ServerSelectors, useAppSelector } from '@app/store';
import type { Data } from '@app/types';
export interface PlayerViewModel {
/** Resolved username from the route; null when the `:name` param is missing. */
name: string | null;
/** Profile record from server.userInfo[name]; undefined until the server response lands. */
userInfo: Data.ServerInfo_User | undefined;
/** The logged-in user, for self-profile and mod-permission checks. */
currentUser: Data.ServerInfo_User | null;
isSelf: boolean;
isABuddy: boolean;
isIgnored: boolean;
isModerator: boolean;
onAddBuddy: () => void;
onRemoveBuddy: () => void;
onAddIgnore: () => void;
onRemoveIgnore: () => void;
onSendMessage: (message: string) => void;
onWarnUser: (reason: string) => void;
onBanFromServer: (minutes: number, reason: string, visibleReason?: string) => void;
}
/**
* Drives the Player container: resolves the `:name` route param, dispatches
* `getUserInfo` on mount so the server populates `server.userInfo[name]`, and
* exposes the buddy/ignore/mod-action callbacks desktop surfaces in UserInfoBox.
*/
export function usePlayer(): PlayerViewModel {
const webClient = useWebClient();
const params = useParams<{ name?: string }>();
const name = params.name ?? null;
const userInfo = useAppSelector((state) =>
name ? ServerSelectors.getUserInfoByName(state, name) : undefined,
);
const currentUser = useAppSelector(ServerSelectors.getUser);
const buddyList = useAppSelector(ServerSelectors.getBuddyList);
const ignoreList = useAppSelector(ServerSelectors.getIgnoreList);
const isModerator = useAppSelector(ServerSelectors.getIsUserModerator);
useEffect(() => {
if (name) {
webClient.request.session.getUserInfo(name);
}
}, [name, webClient]);
const { isSelf, isABuddy, isIgnored } = useMemo(() => ({
isSelf: Boolean(currentUser && name && currentUser.name === name),
isABuddy: Boolean(name && buddyList[name]),
isIgnored: Boolean(name && ignoreList[name]),
}), [currentUser, name, buddyList, ignoreList]);
const onAddBuddy = () => name && webClient.request.session.addToBuddyList(name);
const onRemoveBuddy = () => name && webClient.request.session.removeFromBuddyList(name);
const onAddIgnore = () => name && webClient.request.session.addToIgnoreList(name);
const onRemoveIgnore = () => name && webClient.request.session.removeFromIgnoreList(name);
const onSendMessage = (message: string) => name && webClient.request.session.sendDirectMessage(name, message);
const onWarnUser = (reason: string) => name && webClient.request.moderator.warnUser(name, reason);
const onBanFromServer = (minutes: number, reason: string, visibleReason?: string) =>
name && webClient.request.moderator.banFromServer(minutes, name, undefined, reason, visibleReason);
return {
name,
userInfo,
currentUser,
isSelf,
isABuddy,
isIgnored,
isModerator,
onAddBuddy,
onRemoveBuddy,
onAddIgnore,
onRemoveIgnore,
onSendMessage,
onWarnUser,
onBanFromServer,
};
}

View file

@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
@ -10,6 +10,7 @@ import {
RoomsSelectors,
ServerSelectors,
useAppSelector,
type GameFilters,
} from '@app/store';
import { useReduxEffect, useWebClient } from '@app/hooks';
import { App, type Enriched } from '@app/types';
@ -115,7 +116,7 @@ const GameSelector = ({ room }: GameSelectorProps) => {
setCreateOpen(false);
};
const handleFilterSubmit = (next) => {
const handleFilterSubmit = (next: GameFilters) => {
RoomsDispatch.setGameFilters(roomId, next);
setFilterOpen(false);
};

View file

@ -1,4 +1,3 @@
import React from 'react';
import Button from '@mui/material/Button';
import FilterListIcon from '@mui/icons-material/FilterList';
import FilterListOffIcon from '@mui/icons-material/FilterListOff';

View file

@ -1,30 +0,0 @@
.games {
}
.games-header,
.game {
display: flex;
padding: 10px;
border-bottom: 1px solid black;
}
.games-header__cell {
max-width: 200px;
}
.games-header__label,
.game__detail {
width: 10%;
flex-grow: 0;
}
.games-header__label.description,
.game__detail.description {
width: 20%;
flex-grow: 1;
}
.games-header__label.creator,
.game__detail.creator {
width: 20%;
}

View file

@ -1,90 +0,0 @@
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import TableSortLabel from '@mui/material/TableSortLabel';
import Tooltip from '@mui/material/Tooltip';
import { UserDisplay } from '@app/components';
import { useGames } from './useGames';
import './Games.css';
// @TODO run interval to update timeSinceCreated
interface GamesProps {
room: any;
}
const Games = ({ room }: GamesProps) => {
const roomId = room.info.roomId;
const { sortBy, games, handleSort } = useGames(roomId);
const headerCells = [
{ label: 'Age', field: 'info.startTime' },
{ label: 'Description', field: 'info.description' },
{ label: 'Creator', field: 'info.creatorInfo.name' },
{ label: 'Type', field: 'gameType' },
{ label: 'Restrictions' },
{ label: 'Players' },
{ label: 'Spectators', field: 'info.spectatorsCount' },
];
return (
<div className="games">
<Table size="small">
<TableHead>
<TableRow>
{headerCells.map(({ label, field }) => {
const active = field === sortBy.field;
const order = sortBy.order.toLowerCase();
const sortDirection = active ? (order === 'asc' ? 'asc' : 'desc') : false;
return (
<TableCell sortDirection={sortDirection} key={label}>
{!field ? label : (
<TableSortLabel
active={active}
direction={order}
onClick={() => handleSort(field)}
>
{label}
</TableSortLabel>
)}
</TableCell>
);
})}
</TableRow>
</TableHead>
<TableBody>
{games.map((game) => {
const { info, gameType } = game;
const { description, gameId, creatorInfo, maxPlayers, playerCount, spectatorsCount, startTime } = info;
return (
<TableRow key={gameId}>
<TableCell className="games-header__cell single-line-ellipsis">{startTime}</TableCell>
<TableCell className="games-header__cell">
<Tooltip title={description} placement="bottom-start" enterDelay={500}>
<div className="single-line-ellipsis">
{description}
</div>
</Tooltip>
</TableCell>
<TableCell className="games-header__cell">
<UserDisplay user={creatorInfo} />
</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{gameType}</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">?</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{`${playerCount}/${maxPlayers}`}</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{spectatorsCount}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
};
export default Games;

View file

@ -1,15 +1,17 @@
// eslint-disable-next-line
import React from "react";
import { Message } from '@app/components';
import type { Enriched } from '@app/types';
import './Messages.css';
const Messages = ({ messages }) => (
interface MessagesProps {
messages?: Enriched.Message[];
}
const Messages = ({ messages }: MessagesProps) => (
<div className="messages">
{
messages && messages.map((message) => (
<div className="message-wrapper" key={message.timeReceived}>
messages && messages.map((message, idx) => (
<div className="message-wrapper" key={`${message.timeReceived}-${idx}`}>
<Message message={message} />
</div>
))

View file

@ -14,7 +14,7 @@ import { useOpenGames } from './useOpenGames';
import './OpenGames.css';
interface OpenGamesProps {
room: { info: { roomId: number } };
room: Enriched.Room;
onActivateGame?: (gameId: number) => void;
}

View file

@ -1,14 +1,17 @@
import React from 'react';
import { Form } from 'react-final-form'
import { Form } from 'react-final-form';
import { InputAction } from '@app/components';
const SayMessage = ({ onSubmit }) => (
interface SayMessageProps {
onSubmit: (args: { message: string }) => void;
}
const SayMessage = ({ onSubmit }: SayMessageProps) => (
<Form onSubmit={onSubmit}>
{({ handleSubmit, form }) => (
<form onSubmit={e => {
handleSubmit(e)
form.restart()
handleSubmit(e);
form.restart();
}}>
<InputAction action="Send" label="Chat" name="message" />
</form>

View file

@ -1,33 +0,0 @@
import { SortUtil, RoomsDispatch, RoomsSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
export interface Games {
sortBy: { field: string; order: string };
games: any[];
handleSort: (sortByField: string) => void;
}
export function useGames(roomId: number): Games {
const sortBy = useAppSelector((state) => RoomsSelectors.getSortGamesBy(state));
const sortedGames = useAppSelector((state) => RoomsSelectors.getSortedRoomGames(state, roomId));
const handleSort = (sortByField: string) => {
const { field, order } = SortUtil.toggleSortBy(sortByField as App.GameSortField, sortBy);
RoomsDispatch.sortGames(roomId, field, order);
};
const isUnavailableGame = ({ started, maxPlayers, playerCount }) =>
!started && playerCount < maxPlayers;
const isPasswordProtectedGame = ({ withPassword }) => !withPassword;
const isBuddiesOnlyGame = ({ onlyBuddies }) => !onlyBuddies;
const games = sortedGames.filter((game) => (
isUnavailableGame(game.info) &&
isPasswordProtectedGame(game.info) &&
isBuddiesOnlyGame(game.info)
));
return { sortBy, games, handleSort };
}

View file

@ -1,15 +1,15 @@
import { useEffect } from 'react';
import { useCallback, useEffect } from 'react';
import { useNavigate, useParams, generatePath } from 'react-router-dom';
import { useWebClient } from '@app/hooks';
import { RoomsSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
import { App, Data, Enriched } from '@app/types';
export interface Room {
roomId: number;
room: any;
roomMessages: any;
users: any[];
room: Enriched.Room | undefined;
roomMessages: Enriched.Message[] | undefined;
users: Data.ServerInfo_User[];
handleRoomSay: (args: { message: string }) => void;
}
@ -20,23 +20,24 @@ export function useRoom(): Room {
const navigate = useNavigate();
const params = useParams();
const roomId = parseInt(params.roomId, 10);
const room = rooms[roomId];
const roomMessages = messages[roomId];
const parsed = params.roomId != null ? parseInt(params.roomId, 10) : NaN;
const roomId = Number.isNaN(parsed) ? -1 : parsed;
const room = roomId === -1 ? undefined : rooms[roomId];
const roomMessages = roomId === -1 ? undefined : messages[roomId];
const users = useAppSelector((state) => RoomsSelectors.getSortedRoomUsers(state, roomId));
const webClient = useWebClient();
useEffect(() => {
if (!joined.find((r) => r.info.roomId === roomId)) {
if (roomId === -1 || !joined.find((r) => r.info.roomId === roomId)) {
navigate(generatePath(App.RouteEnum.SERVER));
}
}, [joined]);
}, [joined, roomId, navigate]);
const handleRoomSay = ({ message }: { message: string }) => {
const handleRoomSay = useCallback(({ message }: { message: string }) => {
if (message) {
webClient.request.rooms.roomSay(roomId, message);
}
};
}, [webClient, roomId]);
return { roomId, room, roomMessages, users, handleRoomSay };
}

View file

@ -1,6 +1,3 @@
.rooms {
}
.rooms-header,
.room {
display: flex;

View file

@ -1,5 +1,4 @@
// eslint-disable-next-line
import React from "react";
import { useMemo } from 'react';
import { generatePath, useNavigate } from 'react-router-dom';
import Button from '@mui/material/Button';
@ -10,21 +9,31 @@ import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import { useWebClient } from '@app/hooks';
import { App } from '@app/types';
import { App, Enriched } from '@app/types';
import './Rooms.css';
const Rooms = ({ rooms, joinedRooms }) => {
interface RoomsProps {
rooms: Record<number, Enriched.Room>;
joinedRooms: Enriched.Room[];
}
const Rooms = ({ rooms, joinedRooms }: RoomsProps) => {
const navigate = useNavigate();
const webClient = useWebClient();
function onClick(roomId) {
if (joinedRooms.find(room => room.info.roomId === roomId)) {
navigate(generatePath(App.RouteEnum.ROOM, { roomId }));
const joinedRoomIds = useMemo(
() => new Set(joinedRooms.map((room) => room.info.roomId)),
[joinedRooms],
);
const onClick = (roomId: number) => {
if (joinedRoomIds.has(roomId)) {
navigate(generatePath(App.RouteEnum.ROOM, { roomId: String(roomId) }));
} else {
webClient.request.rooms.joinRoom(roomId);
}
}
};
return (
<div className="rooms">
@ -40,7 +49,7 @@ const Rooms = ({ rooms, joinedRooms }) => {
</TableRow>
</TableHead>
<TableBody>
{ Object.values(rooms).map((room) => {
{Object.values(rooms).map((room) => {
const { description, gameCount, name, permissionlevel, playerCount, roomId } = room.info;
return (
<TableRow key={roomId}>

View file

@ -1,4 +1,4 @@
import React from 'react';
import { useMemo } from 'react';
import { generatePath, useNavigate } from 'react-router-dom';
import ListItemButton from '@mui/material/ListItemButton';
@ -6,9 +6,8 @@ import Paper from '@mui/material/Paper';
import { AuthGuard, ThreePaneLayout, UserDisplay, VirtualList } from '@app/components';
import { useReduxEffect } from '@app/hooks';
import { RoomsSelectors, RoomsTypes, ServerSelectors } from '@app/store';
import { App } from '@app/types';
import { useAppSelector } from '@app/store';
import { RoomsSelectors, RoomsTypes, ServerSelectors, useAppSelector } from '@app/store';
import { App, Data } from '@app/types';
import Rooms from './Rooms';
import Layout from '../Layout/Layout';
@ -21,11 +20,20 @@ const Server = () => {
const users = useAppSelector(state => ServerSelectors.getSortedUsers(state));
const navigate = useNavigate();
useReduxEffect((action: any) => {
useReduxEffect<{ roomInfo: Data.ServerInfo_Room }>((action) => {
const roomId = action.payload.roomInfo.roomId.toString();
navigate(generatePath(App.RouteEnum.ROOM, { roomId }));
}, RoomsTypes.JOIN_ROOM, []);
const userItems = useMemo(
() => users.map((user) => (
<ListItemButton key={user.name} dense>
<UserDisplay user={user} />
</ListItemButton>
)),
[users],
);
return (
<Layout className="server-rooms">
<AuthGuard />
@ -49,13 +57,7 @@ const Server = () => {
<div className="server-rooms__side-label">
Users connected to server: {users.length}
</div>
<VirtualList
items={ users.map(user => (
<ListItemButton key={user.name} dense>
<UserDisplay user={user} />
</ListItemButton>
)) }
/>
<VirtualList items={userItems} />
</Paper>
)}
/>

View file

@ -1,13 +1,3 @@
.dialog-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.MuiDialogTitle-root.dialog-title {
padding-bottom: 0;
}
.content {
margin-bottom: 20px;
}
.content {
margin-bottom: 20px;
}

View file

@ -1,43 +1,35 @@
import React from 'react';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import type { AccountActivationFormValues } from '@app/forms';
import { AccountActivationForm } from '@app/forms';
import AuthDialogShell from '../AuthDialogShell/AuthDialogShell';
import './AccountActivationDialog.css';
const AccountActivationDialog = ({ handleClose, isOpen, onSubmit }: any) => {
interface AccountActivationDialogProps {
isOpen: boolean;
handleClose?: () => void;
onSubmit: (values: AccountActivationFormValues) => void;
}
const AccountActivationDialog = ({ handleClose, isOpen, onSubmit }: AccountActivationDialogProps) => {
const { t } = useTranslation();
const handleOnClose = () => {
handleClose();
}
return (
<Dialog onClose={handleOnClose} open={isOpen}>
<DialogTitle className="dialog-title">
<Typography variant="h6">{ t('AccountActivationDialog.title') }</Typography>
<AuthDialogShell
isOpen={isOpen}
handleClose={handleClose}
title={t('AccountActivationDialog.title')}
>
<div className="content">
<Typography variant='subtitle1'>{ t('AccountActivationDialog.subtitle1') }</Typography>
<Typography variant='subtitle1'>{ t('AccountActivationDialog.subtitle2') }</Typography>
</div>
{handleOnClose ? (
<IconButton onClick={handleOnClose} size="large">
<CloseIcon />
</IconButton>
) : null}
</DialogTitle>
<DialogContent>
<div className="content">
<Typography variant='subtitle1'>{ t('AccountActivationDialog.subtitle1') }</Typography>
<Typography variant='subtitle1'>{ t('AccountActivationDialog.subtitle2') }</Typography>
</div>
<AccountActivationForm onSubmit={onSubmit}></AccountActivationForm>
</DialogContent>
</Dialog>
<AccountActivationForm onSubmit={onSubmit} />
</AuthDialogShell>
);
};

View file

@ -1,5 +1,9 @@
.dialog-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.MuiDialogTitle-root.dialog-title {
padding-bottom: 0;
}

View file

@ -0,0 +1,55 @@
import { ReactNode } from 'react';
import Dialog, { DialogProps } from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import CloseIcon from '@mui/icons-material/Close';
import './AuthDialogShell.css';
export interface AuthDialogShellProps {
isOpen: boolean;
handleClose?: () => void;
title: string;
children: ReactNode;
className?: string;
contentClassName?: string;
maxWidth?: DialogProps['maxWidth'];
}
const AuthDialogShell = ({
isOpen,
handleClose,
title,
children,
className,
contentClassName,
maxWidth,
}: AuthDialogShellProps) => {
const closeGuarded = handleClose ? () => handleClose() : undefined;
return (
<Dialog
className={className}
onClose={closeGuarded}
open={isOpen}
maxWidth={maxWidth}
>
<DialogTitle className="dialog-title">
<Typography variant="h6">{title}</Typography>
{closeGuarded ? (
<IconButton onClick={closeGuarded} size="large">
<CloseIcon />
</IconButton>
) : null}
</DialogTitle>
<DialogContent className={contentClassName}>
{children}
</DialogContent>
</Dialog>
);
};
export default AuthDialogShell;

View file

@ -1,4 +1,3 @@
import React from 'react';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
@ -10,24 +9,23 @@ import { CardImportForm } from '@app/forms';
import './CardImportDialog.css';
const CardImportDialog = ({ handleClose, isOpen }: any) => {
const handleOnClose = () => {
handleClose();
}
export interface CardImportDialogProps {
isOpen: boolean;
handleClose: () => void;
}
const CardImportDialog = ({ handleClose, isOpen }: CardImportDialogProps) => {
return (
<Dialog onClose={handleOnClose} open={isOpen}>
<Dialog onClose={handleClose} open={isOpen}>
<DialogTitle className="dialog-title">
<Typography variant="h2">Import Cards</Typography>
{handleOnClose ? (
<IconButton onClick={handleOnClose} size="large">
<CloseIcon />
</IconButton>
) : null}
<IconButton onClick={handleClose} size="large">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
<CardImportForm onSubmit={handleOnClose}></CardImportForm>
<CardImportForm onSubmit={handleClose} />
</DialogContent>
</Dialog>
);

View file

@ -88,7 +88,20 @@ function CreateCounterDialog({ isOpen, onSubmit, onCancel }: CreateCounterDialog
helperText={error ?? ''}
slotProps={{ htmlInput: { 'aria-label': 'Counter name' } }}
/>
<div className="create-counter-dialog__swatches" role="radiogroup" aria-label="Counter color">
<div
className="create-counter-dialog__swatches"
role="radiogroup"
aria-label="Counter color"
onKeyDown={(e) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIdx((selectedIdx + 1) % SWATCHES.length);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIdx((selectedIdx - 1 + SWATCHES.length) % SWATCHES.length);
}
}}
>
{SWATCHES.map((s, idx) => (
<button
key={s.label}
@ -96,6 +109,7 @@ function CreateCounterDialog({ isOpen, onSubmit, onCancel }: CreateCounterDialog
role="radio"
aria-checked={idx === selectedIdx}
aria-label={s.label}
tabIndex={idx === selectedIdx ? 0 : -1}
className={cx('create-counter-dialog__swatch', {
'create-counter-dialog__swatch--selected': idx === selectedIdx,
})}

View file

@ -1,6 +1,44 @@
.create-token-dialog__body {
display: flex;
gap: 24px;
min-width: 720px;
min-height: 420px;
}
.create-token-dialog__chooser {
display: flex;
flex-direction: column;
gap: 8px;
width: 320px;
min-height: 0;
}
.create-token-dialog__chooser-list {
flex: 1 1 auto;
min-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-subtle, #ddd);
border-radius: 4px;
}
.create-token-dialog__chooser-empty {
padding: 12px;
color: var(--text-muted, #666);
font-style: italic;
}
.create-token-dialog__preview {
margin-top: 8px;
padding: 8px 12px;
border-radius: 4px;
background-color: var(--bg-subtle, #f5f5f5);
font-size: 0.9em;
}
.create-token-dialog__form {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1 1 auto;
min-width: 320px;
}

View file

@ -12,6 +12,11 @@ import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import InputLabel from '@mui/material/InputLabel';
import FormControl from '@mui/material/FormControl';
import Radio from '@mui/material/Radio';
import RadioGroup from '@mui/material/RadioGroup';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import {
MAX_ANNOTATION_LEN,
@ -43,12 +48,15 @@ export interface CreateTokenSubmit {
annotation: string;
destroyOnZoneChange: boolean;
faceDown: boolean;
providerId?: string;
}
export interface CreateTokenDialogProps {
isOpen: boolean;
onSubmit: (args: CreateTokenSubmit) => void;
onCancel: () => void;
/** Optional deck-scoped predefined token names; enables the "Deck" radio in the chooser. */
predefinedTokenNames?: string[];
}
// Matches desktop DlgCreateToken color dropdown values. Desktop orders
@ -64,7 +72,7 @@ const COLOR_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
{ value: '', label: 'Colorless' },
];
function CreateTokenDialog({ isOpen, onSubmit, onCancel }: CreateTokenDialogProps) {
function CreateTokenDialog({ isOpen, onSubmit, onCancel, predefinedTokenNames }: CreateTokenDialogProps) {
const {
name,
color,
@ -73,6 +81,13 @@ function CreateTokenDialog({ isOpen, onSubmit, onCancel }: CreateTokenDialogProp
destroyOnZoneChange,
faceDown,
error,
scope,
search,
filteredTokens,
selectedTokenName,
setScope,
setSearch,
selectPredefinedToken,
handleNameChange,
setColor,
setPT,
@ -80,7 +95,9 @@ function CreateTokenDialog({ isOpen, onSubmit, onCancel }: CreateTokenDialogProp
setDestroyOnZoneChange,
setFaceDown,
handleSubmit,
} = useCreateTokenDialog({ isOpen, onSubmit });
} = useCreateTokenDialog({ isOpen, onSubmit, predefinedTokenNames });
const hasDeckScope = Boolean(predefinedTokenNames?.length);
return (
<StyledDialog
@ -96,74 +113,134 @@ function CreateTokenDialog({ isOpen, onSubmit, onCancel }: CreateTokenDialogProp
</DialogTitle>
<form onSubmit={handleSubmit}>
<DialogContent className="dialog-content create-token-dialog__body">
<TextField
autoFocus
fullWidth
variant="outlined"
size="small"
label="Token name"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
error={error != null}
helperText={error ?? ''}
slotProps={{ htmlInput: { 'aria-label': 'Token name', maxLength: MAX_NAME_LEN } }}
/>
<FormControl fullWidth size="small" variant="outlined" disabled={faceDown}>
<InputLabel id="create-token-color-label">Color</InputLabel>
<Select
labelId="create-token-color-label"
label="Color"
value={color}
onChange={(e) => setColor(e.target.value)}
slotProps={{ input: { 'aria-label': 'Token color' } }}
<div className="create-token-dialog__chooser">
<RadioGroup
row
value={scope}
onChange={(e) => setScope(e.target.value as 'all' | 'deck')}
aria-label="Token source"
>
{COLOR_OPTIONS.map((opt) => (
<MenuItem key={opt.label} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
variant="outlined"
size="small"
label="Token power/toughness"
placeholder="e.g. 3/3"
value={pt}
onChange={(e) => setPT(e.target.value.slice(0, MAX_PT_LEN))}
disabled={faceDown}
slotProps={{ htmlInput: { 'aria-label': 'Token power/toughness', maxLength: MAX_PT_LEN } }}
/>
<TextField
fullWidth
variant="outlined"
size="small"
label="Token annotation"
value={annotation}
onChange={(e) => setAnnotation(e.target.value.slice(0, MAX_ANNOTATION_LEN))}
slotProps={{ htmlInput: { 'aria-label': 'Token annotation', maxLength: MAX_ANNOTATION_LEN } }}
/>
<FormControlLabel
control={
<Checkbox
checked={destroyOnZoneChange}
onChange={(e) => setDestroyOnZoneChange(e.target.checked)}
slotProps={{ input: { 'aria-label': 'Destroy when it leaves the table' } }}
<FormControlLabel value="all" control={<Radio size="small" />} label="All Tokens" />
<FormControlLabel
value="deck"
control={<Radio size="small" />}
label="Deck Tokens"
disabled={!hasDeckScope}
/>
}
label="Destroy when it leaves the table"
/>
<FormControlLabel
control={
<Checkbox
checked={faceDown}
onChange={(e) => setFaceDown(e.target.checked)}
slotProps={{ input: { 'aria-label': 'Create face-down' } }}
/>
}
label="Create face-down"
/>
</RadioGroup>
<TextField
fullWidth
variant="outlined"
size="small"
label="Search tokens"
value={search}
onChange={(e) => setSearch(e.target.value)}
slotProps={{ htmlInput: { 'aria-label': 'Search tokens' } }}
/>
<div className="create-token-dialog__chooser-list">
{filteredTokens.length === 0 ? (
<div className="create-token-dialog__chooser-empty">
No predefined tokens available.
</div>
) : (
<List dense disablePadding>
{filteredTokens.map((token) => {
const tokenName = token.name?.value ?? '';
return (
<ListItemButton
key={tokenName}
selected={tokenName === selectedTokenName}
onClick={() => selectPredefinedToken(token)}
>
<ListItemText
primary={tokenName}
secondary={token.prop?.value?.type?.value}
/>
</ListItemButton>
);
})}
</List>
)}
</div>
{selectedTokenName && (
<div className="create-token-dialog__preview">
<strong>{selectedTokenName}</strong>
{pt ? `${pt}` : ''}
</div>
)}
</div>
<div className="create-token-dialog__form">
<TextField
autoFocus
fullWidth
variant="outlined"
size="small"
label="Token name"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
error={error != null}
helperText={error ?? ''}
disabled={faceDown}
slotProps={{ htmlInput: { 'aria-label': 'Token name', maxLength: MAX_NAME_LEN } }}
/>
<FormControl fullWidth size="small" variant="outlined" disabled={faceDown}>
<InputLabel id="create-token-color-label">Color</InputLabel>
<Select
labelId="create-token-color-label"
label="Color"
value={color}
onChange={(e) => setColor(e.target.value)}
slotProps={{ input: { 'aria-label': 'Token color' } }}
>
{COLOR_OPTIONS.map((opt) => (
<MenuItem key={opt.label} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
variant="outlined"
size="small"
label="Token power/toughness"
placeholder="e.g. 3/3"
value={pt}
onChange={(e) => setPT(e.target.value.slice(0, MAX_PT_LEN))}
disabled={faceDown}
slotProps={{ htmlInput: { 'aria-label': 'Token power/toughness', maxLength: MAX_PT_LEN } }}
/>
<TextField
fullWidth
variant="outlined"
size="small"
label="Token annotation"
value={annotation}
onChange={(e) => setAnnotation(e.target.value.slice(0, MAX_ANNOTATION_LEN))}
slotProps={{ htmlInput: { 'aria-label': 'Token annotation', maxLength: MAX_ANNOTATION_LEN } }}
/>
<FormControlLabel
control={
<Checkbox
checked={destroyOnZoneChange}
onChange={(e) => setDestroyOnZoneChange(e.target.checked)}
slotProps={{ input: { 'aria-label': 'Destroy when it leaves the table' } }}
/>
}
label="Destroy when it leaves the table"
/>
<FormControlLabel
control={
<Checkbox
checked={faceDown}
onChange={(e) => setFaceDown(e.target.checked)}
slotProps={{ input: { 'aria-label': 'Create face-down' } }}
/>
}
label="Create face-down"
/>
</div>
</DialogContent>
<DialogActions>
<Button type="button" onClick={onCancel}>Cancel</Button>

View file

@ -1,7 +1,11 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { TokenDTO } from '@app/services';
import type { CreateTokenSubmit } from './CreateTokenDialog';
export type ChooserScope = 'all' | 'deck';
export interface CreateTokenDialogState {
name: string;
color: string;
@ -10,6 +14,17 @@ export interface CreateTokenDialogState {
destroyOnZoneChange: boolean;
faceDown: boolean;
error: string | null;
scope: ChooserScope;
search: string;
availableTokens: TokenDTO[];
filteredTokens: TokenDTO[];
selectedTokenName: string | null;
setScope: (value: ChooserScope) => void;
setSearch: (value: string) => void;
selectPredefinedToken: (token: TokenDTO) => void;
handleNameChange: (value: string) => void;
setColor: (value: string) => void;
setPT: (value: string) => void;
@ -31,11 +46,39 @@ export const MAX_ANNOTATION_LEN = 255;
export interface UseCreateTokenDialogArgs {
isOpen: boolean;
onSubmit: (args: CreateTokenSubmit) => void;
/** Optional deck-scoped token names; mirrors desktop DlgCreateToken predefinedTokens. */
predefinedTokenNames?: string[];
}
/** Maps a MTGJSON-shaped color list ("W", "U", ...) to the dialog's single-letter color value. */
function colorFromToken(token: TokenDTO): string {
const raw = token.prop?.value?.colors?.value ?? '';
if (!raw) {
return '';
}
const colors = raw.split(/[\s,]+/).filter(Boolean).map((c: string) => c.toLowerCase());
if (colors.length === 0) {
return '';
}
if (colors.length > 1) {
return 'm';
}
const first = colors[0];
if (first === 'w' || first === 'u' || first === 'b' || first === 'r' || first === 'g') {
return first;
}
return '';
}
/** Best-effort providerId from the token's first set entry; matches desktop TokenInfo.providerId. */
function providerIdFromToken(token: TokenDTO): string | undefined {
return token.set?.[0]?.value ?? undefined;
}
export function useCreateTokenDialog({
isOpen,
onSubmit,
predefinedTokenNames,
}: UseCreateTokenDialogArgs): CreateTokenDialogState {
const [name, setName] = useState('');
const [color, setColor] = useState(CREATE_TOKEN_DEFAULT_COLOR);
@ -45,6 +88,12 @@ export function useCreateTokenDialog({
const [faceDown, setFaceDown] = useState(false);
const [error, setError] = useState<string | null>(null);
const [scope, setScope] = useState<ChooserScope>(predefinedTokenNames?.length ? 'deck' : 'all');
const [search, setSearch] = useState('');
const [availableTokens, setAvailableTokens] = useState<TokenDTO[]>([]);
const [selectedTokenName, setSelectedTokenName] = useState<string | null>(null);
const [providerId, setProviderId] = useState<string | undefined>(undefined);
useEffect(() => {
if (isOpen) {
setName('');
@ -54,9 +103,53 @@ export function useCreateTokenDialog({
setDestroyOnZoneChange(true);
setFaceDown(false);
setError(null);
setSearch('');
setSelectedTokenName(null);
setProviderId(undefined);
setScope(predefinedTokenNames?.length ? 'deck' : 'all');
}
}, [isOpen, predefinedTokenNames]);
useEffect(() => {
if (!isOpen) {
return;
}
let cancelled = false;
// Best-effort load of the token library. On failure the chooser renders
// empty and freeform creation still works.
import('@app/services').then(({ dexieService }) => {
dexieService.tokens.toArray().then((tokens: TokenDTO[]) => {
if (!cancelled) {
setAvailableTokens(tokens);
}
}).catch(() => {
if (!cancelled) {
setAvailableTokens([]);
}
});
});
return () => {
cancelled = true;
};
}, [isOpen]);
const filteredTokens = useMemo(() => {
const allowByScope = scope === 'deck' && predefinedTokenNames?.length
? new Set(predefinedTokenNames.map((n) => n.toLowerCase()))
: null;
const needle = search.trim().toLowerCase();
return availableTokens.filter((token) => {
const tokenName = token.name?.value ?? '';
if (allowByScope && !allowByScope.has(tokenName.toLowerCase())) {
return false;
}
if (needle && !tokenName.toLowerCase().includes(needle)) {
return false;
}
return true;
});
}, [availableTokens, scope, search, predefinedTokenNames]);
const handleNameChange = (value: string) => {
setName(value.slice(0, MAX_NAME_LEN));
if (error) {
@ -64,20 +157,37 @@ export function useCreateTokenDialog({
}
};
const selectPredefinedToken = (token: TokenDTO) => {
const tokenName = token.name?.value ?? '';
setSelectedTokenName(tokenName);
setName(tokenName.slice(0, MAX_NAME_LEN));
setColor(colorFromToken(token));
const ptRaw = token.prop?.value?.pt?.value ?? '';
setPT(ptRaw.slice(0, MAX_PT_LEN));
setProviderId(providerIdFromToken(token));
if (error) {
setError(null);
}
};
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
if (name.trim().length === 0) {
setError('Name is required');
return;
}
onSubmit({
const payload: CreateTokenSubmit = {
name: name.trim(),
color,
pt: pt.trim(),
annotation: annotation.trim(),
destroyOnZoneChange,
faceDown,
});
};
if (providerId) {
payload.providerId = providerId;
}
onSubmit(payload);
};
return {
@ -88,6 +198,14 @@ export function useCreateTokenDialog({
destroyOnZoneChange,
faceDown,
error,
scope,
search,
availableTokens,
filteredTokens,
selectedTokenName,
setScope,
setSearch,
selectPredefinedToken,
handleNameChange,
setColor,
setPT,

View file

@ -21,6 +21,13 @@ import './FilterGamesDialog.css';
export interface FilterGamesDialogProps {
isOpen: boolean;
/**
* MUST be a stable reference across renders while the dialog is open.
* The open-reset effect depends on `initialFilters` identity; an unstable
* reference (e.g. `{ ...defaults }` freshly constructed every parent render)
* will reset the draft form on every re-render. Pass a memoized value or a
* module-level constant.
*/
initialFilters: GameFilters;
gametypeMap: Enriched.GametypeMap;
onCancel: () => void;

View file

@ -1,26 +1,8 @@
.KnownHostDialog {
}
.KnownHostDialog .MuiDialog-paper {
width: 100%;
max-width: 420px;
}
.dialog-title__wrapper {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid;
padding-bottom: 10px;
}
.dialog-title__label {
display: flex;
align-items: center;
}
.dialog-content__subtitle.MuiTypography-root {
margin-bottom: 20px;
}

View file

@ -1,62 +1,40 @@
import React from 'react';
import { styled } from '@mui/material/styles';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import type { KnownHostFormValues } from '@app/forms';
import { KnownHostForm } from '@app/forms';
import type { HostDTO } from '@app/services';
import AuthDialogShell from '../AuthDialogShell/AuthDialogShell';
import './KnownHostDialog.css';
const PREFIX = 'KnownHostDialog';
interface KnownHostDialogProps {
isOpen: boolean;
handleClose?: () => void;
onRemove: (host: HostDTO) => void;
onSubmit: (values: KnownHostFormValues) => void;
host?: HostDTO;
}
const classes = {
root: `${PREFIX}-root`
};
const StyledDialog = styled(Dialog)(({ theme }) => ({
[`&.${classes.root}`]: {
'& .dialog-title__wrapper': {
borderColor: theme.palette.grey[300]
}
}
}));
const KnownHostDialog = ({ handleClose, onRemove, onSubmit, isOpen, host }: any) => {
const KnownHostDialog = ({ handleClose, onRemove, onSubmit, isOpen, host }: KnownHostDialogProps) => {
const { t } = useTranslation();
const mode = host ? 'edit' : 'add';
const handleOnClose = () => {
if (handleClose) {
handleClose();
}
};
return (
<StyledDialog className={'KnownHostDialog ' + classes.root} onClose={handleOnClose} open={isOpen}>
<DialogTitle className='dialog-title'>
<div className='dialog-title__wrapper'>
<Typography variant='h2'>{ t('KnownHostDialog.title', { mode }) }</Typography>
{handleClose ? (
<IconButton onClick={handleClose} size="large">
<CloseIcon fontSize='large' />
</IconButton>
) : null}
</div>
</DialogTitle>
<DialogContent className='dialog-content'>
<Typography className='dialog-content__subtitle' variant='subtitle1'>
{ t('KnownHostDialog.subtitle') }
</Typography>
<KnownHostForm onRemove={onRemove} onSubmit={onSubmit} host={host}></KnownHostForm>
</DialogContent>
</StyledDialog>
<AuthDialogShell
className="KnownHostDialog"
contentClassName="dialog-content"
isOpen={isOpen}
handleClose={handleClose}
title={t('KnownHostDialog.title', { mode })}
>
<Typography className="dialog-content__subtitle" variant="subtitle1">
{t('KnownHostDialog.subtitle')}
</Typography>
<KnownHostForm onRemove={onRemove} onSubmit={onSubmit} host={host} />
</AuthDialogShell>
);
};

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
export interface PromptDialog {
export interface PromptDialogHandle {
value: string;
error: string | null;
handleChange: (v: string) => void;
@ -19,7 +19,7 @@ export function usePromptDialog({
initialValue,
validate,
onSubmit,
}: UsePromptDialogArgs): PromptDialog {
}: UsePromptDialogArgs): PromptDialogHandle {
const [value, setValue] = useState(initialValue);
const [error, setError] = useState<string | null>(null);

View file

@ -1,10 +1,4 @@
.dialog-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-content {
width: 700px;
max-width: 100%;
}
.dialog-content {
width: 700px;
max-width: 100%;
}

View file

@ -1,38 +1,32 @@
import React from 'react';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import type { RegisterFormValues } from '@app/forms';
import { RegisterForm } from '@app/forms';
import AuthDialogShell from '../AuthDialogShell/AuthDialogShell';
import './RegistrationDialog.css';
const RegistrationDialog = ({ handleClose, isOpen, onSubmit }: any) => {
interface RegistrationDialogProps {
isOpen: boolean;
handleClose?: () => void;
onSubmit: (values: RegisterFormValues) => void;
}
const RegistrationDialog = ({ handleClose, isOpen, onSubmit }: RegistrationDialogProps) => {
const { t } = useTranslation();
const handleOnClose = () => {
handleClose();
}
return (
<Dialog className="RegistrationDialog" onClose={handleOnClose} open={isOpen} maxWidth='xl'>
<DialogTitle className="dialog-title">
<Typography variant="h6">{ t('RegistrationDialog.title') }</Typography>
{handleOnClose ? (
<IconButton onClick={handleOnClose} size="large">
<CloseIcon />
</IconButton>
) : null}
</DialogTitle>
<DialogContent className="dialog-content">
<RegisterForm onSubmit={onSubmit}></RegisterForm>
</DialogContent>
</Dialog>
<AuthDialogShell
className="RegistrationDialog"
contentClassName="dialog-content"
isOpen={isOpen}
handleClose={handleClose}
title={t('RegistrationDialog.title')}
maxWidth="xl"
>
<RegisterForm onSubmit={onSubmit} />
</AuthDialogShell>
);
};

View file

@ -1,38 +1,33 @@
import React from 'react';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import type { RequestPasswordResetFormValues } from '@app/forms';
import { RequestPasswordResetForm } from '@app/forms';
import './RequestPasswordResetDialog.css';
import AuthDialogShell from '../AuthDialogShell/AuthDialogShell';
const RequestPasswordResetDialog = ({ handleClose, isOpen, onSubmit, skipTokenRequest }: any) => {
interface RequestPasswordResetDialogProps {
isOpen: boolean;
handleClose?: () => void;
onSubmit: (values: RequestPasswordResetFormValues) => void;
skipTokenRequest: (userName: string) => void;
}
const RequestPasswordResetDialog = ({
handleClose,
isOpen,
onSubmit,
skipTokenRequest,
}: RequestPasswordResetDialogProps) => {
const { t } = useTranslation();
const handleOnClose = () => {
handleClose();
}
return (
<Dialog onClose={handleOnClose} open={isOpen}>
<DialogTitle className="dialog-title">
<Typography variant="h6">{ t('RequestPasswordResetDialog.title') }</Typography>
{handleOnClose ? (
<IconButton onClick={handleOnClose} size="large">
<CloseIcon />
</IconButton>
) : null}
</DialogTitle>
<DialogContent>
<RequestPasswordResetForm onSubmit={onSubmit} skipTokenRequest={skipTokenRequest}></RequestPasswordResetForm>
</DialogContent>
</Dialog>
<AuthDialogShell
isOpen={isOpen}
handleClose={handleClose}
title={t('RequestPasswordResetDialog.title')}
>
<RequestPasswordResetForm onSubmit={onSubmit} skipTokenRequest={skipTokenRequest} />
</AuthDialogShell>
);
};

View file

@ -1,5 +0,0 @@
.dialog-title {
display: flex;
justify-content: space-between;
align-items: center;
}

View file

@ -1,38 +1,28 @@
import React from 'react';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import type { ResetPasswordFormValues } from '@app/forms';
import { ResetPasswordForm } from '@app/forms';
import './ResetPasswordDialog.css';
import AuthDialogShell from '../AuthDialogShell/AuthDialogShell';
const ResetPasswordDialog = ({ handleClose, isOpen, onSubmit, userName }: any) => {
interface ResetPasswordDialogProps {
isOpen: boolean;
handleClose?: () => void;
onSubmit: (values: ResetPasswordFormValues) => void;
userName?: string;
}
const ResetPasswordDialog = ({ handleClose, isOpen, onSubmit, userName }: ResetPasswordDialogProps) => {
const { t } = useTranslation();
const handleOnClose = () => {
handleClose();
}
return (
<Dialog onClose={handleOnClose} open={isOpen}>
<DialogTitle className="dialog-title">
<Typography variant="h6">{t('ResetPasswordDialog.title')}</Typography>
{handleOnClose ? (
<IconButton onClick={handleOnClose} size="large">
<CloseIcon />
</IconButton>
) : null}
</DialogTitle>
<DialogContent>
<ResetPasswordForm onSubmit={onSubmit} userName={userName}/>
</DialogContent>
</Dialog>
<AuthDialogShell
isOpen={isOpen}
handleClose={handleClose}
title={t('ResetPasswordDialog.title')}
>
<ResetPasswordForm onSubmit={onSubmit} userName={userName} />
</AuthDialogShell>
);
};

View file

@ -8,6 +8,7 @@ import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
import Tooltip from '@mui/material/Tooltip';
import { App, Enriched } from '@app/types';
@ -84,19 +85,14 @@ function SideboardDialog({
}: SideboardDialogProps) {
const [moves, setMoves] = useState<SideboardPlanMove[]>([]);
// Reset the draft every time the dialog opens, and also when the server
// locks the sideboard mid-edit (desktop's resetSideboardPlan parity).
// Reset the draft whenever the dialog opens, or when the server locks the
// sideboard mid-edit (desktop's resetSideboardPlan parity). Consolidated
// into one effect keyed on both triggers.
useEffect(() => {
if (isOpen) {
if (isOpen || isLocked) {
setMoves([]);
}
}, [isOpen]);
useEffect(() => {
if (isLocked && moves.length > 0) {
setMoves([]);
}
}, [isLocked, moves.length]);
}, [isOpen, isLocked]);
const { deck, sideboard } = useMemo(
() => applyMoves(deckCards, sideboardCards, moves),
@ -166,15 +162,19 @@ function SideboardDialog({
{deck.map((card, idx) => (
<li key={`${card.id}-${idx}`} className="sideboard-dialog__row">
<span className="sideboard-dialog__name">{card.name}</span>
<Button
type="button"
size="small"
onClick={() => handleMoveToSideboard(card)}
disabled={isLocked}
aria-label={`Move ${card.name} to sideboard`}
>
</Button>
<Tooltip title={`Move ${card.name} to sideboard`}>
<span>
<Button
type="button"
size="small"
onClick={() => handleMoveToSideboard(card)}
disabled={isLocked}
aria-label={`Move ${card.name} to sideboard`}
>
</Button>
</span>
</Tooltip>
</li>
))}
{deck.length === 0 && (
@ -192,15 +192,19 @@ function SideboardDialog({
<ul className="sideboard-dialog__list" data-testid="sideboard-dialog-sb">
{sideboard.map((card, idx) => (
<li key={`${card.id}-${idx}`} className="sideboard-dialog__row">
<Button
type="button"
size="small"
onClick={() => handleMoveToDeck(card)}
disabled={isLocked}
aria-label={`Move ${card.name} to main deck`}
>
</Button>
<Tooltip title={`Move ${card.name} to main deck`}>
<span>
<Button
type="button"
size="small"
onClick={() => handleMoveToDeck(card)}
disabled={isLocked}
aria-label={`Move ${card.name} to main deck`}
>
</Button>
</span>
</Tooltip>
<span className="sideboard-dialog__name">{card.name}</span>
</li>
))}

View file

@ -1,5 +1,7 @@
export { default as AccountActivationDialog } from './AccountActivationDialog/AccountActivationDialog';
export { default as AlertDialog } from './AlertDialog/AlertDialog';
export { default as AuthDialogShell } from './AuthDialogShell/AuthDialogShell';
export type { AuthDialogShellProps } from './AuthDialogShell/AuthDialogShell';
export type { AlertDialogProps, AlertDialogSeverity } from './AlertDialog/AlertDialog';
export { default as CardImportDialog } from './CardImportDialog/CardImportDialog';
export { default as ConfirmDialog } from './ConfirmDialog/ConfirmDialog';

View file

@ -1,5 +1,4 @@
// eslint-disable-next-line
import React, { useState } from "react";
import { useState } from 'react';
import { Form, Field } from 'react-final-form';
import { useTranslation } from 'react-i18next';
@ -7,12 +6,21 @@ import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import { InputField } from '@app/components';
import type { FormErrors } from '@app/forms';
import { useReduxEffect } from '@app/hooks';
import { ServerTypes } from '@app/store';
import './AccountActivationForm.css';
const AccountActivationForm = ({ onSubmit }) => {
export interface AccountActivationFormValues {
token: string;
}
interface AccountActivationFormProps {
onSubmit: (values: AccountActivationFormValues) => void;
}
const AccountActivationForm = ({ onSubmit }: AccountActivationFormProps) => {
const [errorMessage, setErrorMessage] = useState(false);
const { t } = useTranslation();
@ -20,16 +28,14 @@ const AccountActivationForm = ({ onSubmit }) => {
setErrorMessage(true);
}, ServerTypes.ACCOUNT_ACTIVATION_FAILED, []);
const handleOnSubmit = ({ token, ...values }) => {
const handleOnSubmit = ({ token, ...values }: AccountActivationFormValues) => {
setErrorMessage(false);
token = token?.trim();
onSubmit({ ...values, token: token?.trim() });
};
onSubmit({ token, ...values });
}
const validate = values => {
const errors: any = {};
const validate = (values: Partial<AccountActivationFormValues>): FormErrors<AccountActivationFormValues> => {
const errors: FormErrors<AccountActivationFormValues> = {};
if (!values.token) {
errors.token = t('Common.validation.required');

View file

@ -0,0 +1,25 @@
{
"CardImportForm": {
"steps": {
"importSets": "Import sets",
"saveSets": "Save sets",
"importTokens": "Import tokens",
"finished": "Finished"
},
"label": {
"downloadUrl": "Download URL"
},
"button": {
"import": "Import",
"save": "Save",
"goBack": "Go Back",
"done": "Done"
},
"message": {
"finished": "Finished!",
"importSummary": "Import finished: {count} cards.",
"setSummary": "{name}: {count} cards imported",
"failedToSave": "Failed to save cards"
}
}
}

View file

@ -1,4 +1,6 @@
import { ReactNode } from 'react';
import { Form, Field } from 'react-final-form';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import Stepper from '@mui/material/Stepper';
@ -7,12 +9,72 @@ import StepLabel from '@mui/material/StepLabel';
import CircularProgress from '@mui/material/CircularProgress';
import { InputField, VirtualList } from '@app/components';
import type { App } from '@app/types';
import { useCardImportForm } from './useCardImportForm';
import './CardImportForm.css';
const CardImportForm = ({ onSubmit: onClose }) => {
const CARDS_URL = 'https://www.mtgjson.com/api/v5/AllPrintings.json';
const TOKENS_URL = 'https://raw.githubusercontent.com/Cockatrice/Magic-Token/master/tokens.xml';
interface CardImportFormProps {
onSubmit: () => void;
}
interface BackButtonProps {
click: () => void;
disabled?: boolean;
}
const BackButton = ({ click, disabled }: BackButtonProps) => {
const { t } = useTranslation();
return (
<Button onClick={click} disabled={disabled}>{t('CardImportForm.button.goBack')}</Button>
);
};
interface ErrorMessageProps {
error: string | null;
}
const ErrorMessage = ({ error }: ErrorMessageProps): ReactNode => (
error ? <div className='error'>{error}</div> : null
);
interface CardsImportedProps {
cards: App.Card[];
sets: App.Set[];
}
const CardsImported = ({ cards, sets }: CardsImportedProps) => {
const { t } = useTranslation();
const items: ReactNode[] = [
(
<div key='import-summary'>
<strong>{t('CardImportForm.message.importSummary', { count: cards.length })}</strong>
</div>
),
(<div key='spacer' className='spacer' />),
...sets.map(set => (
<div key={set.code ?? set.name}>
{t('CardImportForm.message.setSummary', { name: set.name, count: set.cards.length })}
</div>
)),
];
return (
<div className='card-import-list'>
<VirtualList
items={items}
size={15}
/>
</div>
);
};
const CardImportForm = ({ onSubmit: onClose }: CardImportFormProps) => {
const { t } = useTranslation();
const {
loading,
activeStep,
@ -25,24 +87,29 @@ const CardImportForm = ({ onSubmit: onClose }) => {
handleTokenDownload,
} = useCardImportForm();
const steps = ['Imports sets', 'Save sets', 'Import tokens', 'Finished'];
const steps = [
t('CardImportForm.steps.importSets'),
t('CardImportForm.steps.saveSets'),
t('CardImportForm.steps.importTokens'),
t('CardImportForm.steps.finished'),
];
const getStepContent = (stepIndex) => {
const getStepContent = (stepIndex: number): ReactNode => {
switch (stepIndex) {
case 0: return (
<Form
onSubmit={handleCardDownload}
initialValues={{ cardDownloadUrl: 'https://www.mtgjson.com/api/v5/AllPrintings.json' }}
initialValues={{ cardDownloadUrl: CARDS_URL }}
>
{({ handleSubmit }) => (
<form className='cardImportForm' onSubmit={handleSubmit}>
<div className='cardImportForm-item'>
<Field label='Download URL' name='cardDownloadUrl' component={InputField} />
<Field label={t('CardImportForm.label.downloadUrl')} name='cardDownloadUrl' component={InputField} />
</div>
<div className='cardImportForm-actions'>
<Button color='primary' type='submit' disabled={loading}>
Import
{t('CardImportForm.button.import')}
</Button>
</div>
@ -63,7 +130,7 @@ const CardImportForm = ({ onSubmit: onClose }) => {
<div className='cardImportForm-actions'>
<BackButton click={handleBack} disabled={loading} />
<Button color='primary' onClick={handleCardSave} disabled={loading}>
Save
{t('CardImportForm.button.save')}
</Button>
</div>
@ -76,18 +143,18 @@ const CardImportForm = ({ onSubmit: onClose }) => {
case 2: return (
<Form
onSubmit={handleTokenDownload}
initialValues={{ tokenDownloadUrl: 'https://raw.githubusercontent.com/Cockatrice/Magic-Token/master/tokens.xml' }}
initialValues={{ tokenDownloadUrl: TOKENS_URL }}
>
{({ handleSubmit }) => (
<form className='cardImportForm' onSubmit={handleSubmit}>
<div className='cardImportForm-content'>
<Field label='Download URL' name='tokenDownloadUrl' component={InputField} />
<Field label={t('CardImportForm.label.downloadUrl')} name='tokenDownloadUrl' component={InputField} />
</div>
<div className='cardImportForm-actions'>
<BackButton click={handleBack} disabled={loading} />
<Button color='primary' type='submit' disabled={loading}>
Import
{t('CardImportForm.button.import')}
</Button>
</div>
@ -101,14 +168,17 @@ const CardImportForm = ({ onSubmit: onClose }) => {
case 3: return (
<div className='cardImportForm'>
<div className='cardImportForm-content done'>Finished!</div>
<div className='cardImportForm-content done'>{t('CardImportForm.message.finished')}</div>
<div className='cardImportForm-actions'>
<BackButton click={handleBack} disabled={loading} />
<Button color='primary' onClick={onClose}>Done</Button>
<Button color='primary' onClick={onClose}>{t('CardImportForm.button.done')}</Button>
</div>
</div>
);
default:
throw new Error(`CardImportForm: unknown step index ${stepIndex}`);
}
};
@ -135,39 +205,4 @@ const CardImportForm = ({ onSubmit: onClose }) => {
);
};
const BackButton = ({ click, disabled }) => (
<Button onClick={click} disabled={disabled}>Go Back</Button>
);
const ErrorMessage = ({ error }) => {
return error && (
<div className='error'>{error}</div>
);
};
const CardsImported = ({ cards, sets }) => {
const items = [
(
<div>
<strong>Import finished: {cards.length} cards.</strong>
</div>
),
(<div className='spacer' />),
...sets.map(set => (
<div>{set.name}: {set.cards.length} cards imported</div>
))
];
return (
<div className='card-import-list'>
<VirtualList
items={items}
size={15}
/>
</div>
);
};
export default CardImportForm;

View file

@ -1,12 +1,13 @@
import { useEffect, useState } from 'react';
import { cardImporterService, CardDTO, SetDTO, TokenDTO } from '@app/services';
import type { App } from '@app/types';
export interface CardImportForm {
loading: boolean;
activeStep: number;
importedCards: any[];
importedSets: any[];
importedCards: App.Card[];
importedSets: App.Set[];
error: string | null;
handleNext: () => void;
handleBack: () => void;
@ -18,8 +19,8 @@ export interface CardImportForm {
export function useCardImportForm(): CardImportForm {
const [loading, setLoading] = useState(false);
const [activeStep, setActiveStep] = useState(0);
const [importedCards, setImportedCards] = useState<any[]>([]);
const [importedSets, setImportedSets] = useState<any[]>([]);
const [importedCards, setImportedCards] = useState<App.Card[]>([]);
const [importedSets, setImportedSets] = useState<App.Set[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {

View file

@ -1,21 +1,35 @@
// eslint-disable-next-line
import React, { useState } from "react";
import { Form, Field } from 'react-final-form'
import { useState } from 'react';
import { Form, Field } from 'react-final-form';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import AnchorLink from '@mui/material/Link';
import { InputField } from '@app/components';
import type { FormErrors } from '@app/forms';
import type { HostDTO } from '@app/services';
import './KnownHostForm.css';
const KnownHostForm = ({ host, onRemove, onSubmit }) => {
export interface KnownHostFormValues {
id?: number;
name: string;
host: string;
port: string;
}
interface KnownHostFormProps {
host?: HostDTO;
onRemove: (host: HostDTO) => void;
onSubmit: (values: KnownHostFormValues) => void;
}
const KnownHostForm = ({ host, onRemove, onSubmit }: KnownHostFormProps) => {
const [confirmDelete, setConfirmDelete] = useState(false);
const { t } = useTranslation();
const validate = values => {
const errors: any = {};
const validate = (values: Partial<KnownHostFormValues>): FormErrors<KnownHostFormValues> => {
const errors: FormErrors<KnownHostFormValues> = {};
if (!values.name) {
errors.name = t('Common.validation.required');
@ -29,17 +43,27 @@ const KnownHostForm = ({ host, onRemove, onSubmit }) => {
errors.port = t('Common.validation.required');
}
if (Object.keys(errors).length) {
return errors;
}
return errors;
};
const handleOnSubmit = ({ name, host, ...values }) => {
name = name?.trim();
host = host?.trim();
const handleOnSubmit = ({ name, host: hostValue, ...values }: KnownHostFormValues) => {
onSubmit({
...values,
name: name?.trim(),
host: hostValue?.trim(),
});
};
onSubmit({ name, host, ...values });
}
const handleRemoveClick = () => {
if (!host) {
return;
}
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
onRemove(host);
};
return (
<Form
@ -71,7 +95,7 @@ const KnownHostForm = ({ host, onRemove, onSubmit }) => {
<div className="KnownHostForm-actions">
<div className="KnownHostForm-actions__delete">
{ host && (
<Button color="inherit" onClick={() => !confirmDelete ? setConfirmDelete(true) : onRemove(host)}>
<Button color="inherit" onClick={handleRemoveClick}>
{ !confirmDelete ? t('Common.label.delete') : t('Common.label.confirmSure') }
</Button>
) }

View file

@ -18,3 +18,9 @@
.loginForm-submit {
width: 100%;
}
.loginForm-loading {
display: flex;
justify-content: center;
padding: 40px 0;
}

View file

@ -5,7 +5,7 @@
"forgot": "Forgot Password",
"login": "Login",
"savePassword": "Save Password",
"savedPassword": "Saved Password"
"savedPassword": "* Saved Password *"
}
}
}

View file

@ -5,17 +5,28 @@ import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import Checkbox from '@mui/material/Checkbox';
import CircularProgress from '@mui/material/CircularProgress';
import FormControlLabel from '@mui/material/FormControlLabel';
import { CheckboxField, InputField, KnownHosts } from '@app/components';
import type { FormErrors } from '@app/forms';
import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
import { HostDTO } from '@app/services';
import { useLoginFormBody } from './useLoginForm';
import './LoginForm.css';
export interface LoginFormValues {
userName: string;
password: string;
remember: boolean;
autoConnect: boolean;
selectedHost: HostDTO;
}
interface LoginFormProps {
onSubmit: (values: any) => void;
onSubmit: (values: LoginFormValues) => void;
disableSubmitButton: boolean;
onResetPassword: () => void;
}
@ -33,7 +44,7 @@ const LoginFormBody = ({
}: LoginFormBodyProps) => {
const { t } = useTranslation();
const PASSWORD_LABEL = t('Common.label.password');
const STORED_PASSWORD_LABEL = `* ${t('LoginForm.label.savedPassword')} *`;
const STORED_PASSWORD_LABEL = t('LoginForm.label.savedPassword');
const {
useStoredPasswordLabel,
@ -121,8 +132,8 @@ const LoginForm = (props: LoginFormProps) => {
const knownHosts = useKnownHosts();
const settings = useSettings();
const validate = (values: any) => {
const errors: any = {};
const validate = (values: Partial<LoginFormValues>): FormErrors<LoginFormValues> => {
const errors: FormErrors<LoginFormValues> = {};
if (!values.userName) {
errors.userName = t('Common.validation.required');
@ -134,17 +145,20 @@ const LoginForm = (props: LoginFormProps) => {
return errors;
};
const handleOnSubmit = ({ userName, ...values }: any) => {
userName = userName?.trim();
props.onSubmit({ userName, ...values });
const handleOnSubmit = ({ userName, ...values }: LoginFormValues) => {
props.onSubmit({ ...values, userName: userName?.trim() });
};
if (knownHosts.status !== LoadingState.READY || settings.status !== LoadingState.READY) {
return null;
return (
<div className="loginForm-loading">
<CircularProgress size={40} />
</div>
);
}
const selectedHost = knownHosts.value?.selectedHost;
const initialValues = {
const initialValues: Partial<LoginFormValues> = {
selectedHost,
userName: selectedHost?.userName ?? '',
remember: Boolean(selectedHost?.remember),

View file

@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Form, Field } from 'react-final-form';
import { Form, Field, useForm } from 'react-final-form';
import { OnChange } from 'react-final-form-listeners';
import setFieldTouched from 'final-form-set-field-touched';
import { useTranslation } from 'react-i18next';
@ -8,12 +8,42 @@ import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import { CountryDropdown, InputField, KnownHosts } from '@app/components';
import type { FormErrors } from '@app/forms';
import type { HostDTO } from '@app/services';
import { ServerDispatch } from '@app/store';
import { useRegisterForm } from './useRegisterForm';
import './RegisterForm.css';
export interface RegisterFormValues {
userName: string;
password: string;
passwordConfirm: string;
email?: string;
emailConfirm?: string;
realName?: string;
country?: string;
selectedHost: HostDTO;
}
interface RegisterFormProps {
onSubmit: (values: RegisterFormValues) => void;
}
// Drives `setFieldTouched` from inside the react-final-form context so the
// hook lives in a real component body instead of the <Form> render prop,
// where react-final-form might short-circuit rendering and desync hook order.
const EmailTouchOnRequire = ({ emailRequired }: { emailRequired: boolean }) => {
const form = useForm();
useEffect(() => {
if (emailRequired) {
form.mutators.setFieldTouched('email', true);
}
}, [emailRequired, form]);
return null;
};
const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
const { t } = useTranslation();
const {
@ -28,18 +58,19 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
onUserNameChange,
} = useRegisterForm();
const handleOnSubmit = ({ userName, email, realName, ...values }) => {
const handleOnSubmit = (values: RegisterFormValues) => {
ServerDispatch.clearRegistrationErrors();
userName = userName?.trim();
email = email?.trim();
realName = realName?.trim();
onSubmit({ userName, email, realName, ...values });
onSubmit({
...values,
userName: values.userName?.trim(),
email: values.email?.trim(),
realName: values.realName?.trim(),
});
};
const validate = values => {
const errors: any = {};
const validate = (values: Partial<RegisterFormValues>): FormErrors<RegisterFormValues> => {
const errors: FormErrors<RegisterFormValues> = {};
if (!values.userName) {
errors.userName = t('Common.validation.required');
@ -71,83 +102,87 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
errors.email = emailError;
}
if (emailRequired) {
if (!values.emailConfirm) {
errors.emailConfirm = t('Common.validation.required');
} else if (values.email !== values.emailConfirm) {
errors.emailConfirm = t('Common.validation.emailsMustMatch');
}
}
return errors;
};
return (
<Form onSubmit={handleOnSubmit} validate={validate} mutators={{ setFieldTouched }}>
{({ handleSubmit, form }) => {
useEffect(() => {
if (emailRequired) {
form.mutators.setFieldTouched('email', true);
}
}, [emailRequired]);
return (
<>
<form className="RegisterForm" onSubmit={handleSubmit}>
<div className="RegisterForm-column">
<div className="RegisterForm-item">
<Field label={t('Common.label.username')} name="userName" component={InputField} autoComplete="username" />
<OnChange name="userName">{onUserNameChange}</OnChange>
</div>
<div className="RegisterForm-item">
<Field
label={t('Common.label.password')}
name="password"
type="password"
component={InputField}
autoComplete='new-password'
/>
<OnChange name="password">{onPasswordChange}</OnChange>
</div>
<div className="RegisterForm-item">
<Field
label={t('Common.label.confirmPassword')}
name="passwordConfirm"
type="password"
component={InputField}
autoComplete='new-password'
/>
</div>
<div className="RegisterForm-item">
<Field name="selectedHost" component={KnownHosts} />
<OnChange name="selectedHost">{onHostChange}</OnChange>
</div>
</div>
<div className="RegisterForm-column" >
<div className="RegisterForm-item">
<Field label={t('Common.label.realName')} name="realName" component={InputField} />
</div>
<div className="RegisterForm-item">
<Field label={t('Common.label.email')} name="email" type="email" component={InputField} />
<OnChange name="email">{onEmailChange}</OnChange>
</div>
<div className="RegisterForm-item">
<Field label={t('Common.label.country')} name="country" component={CountryDropdown} />
</div>
<Button className="RegisterForm-submit tall" color="primary" variant="contained" type="submit">
{t('RegisterForm.label.register')}
</Button>
</div>
</form>
{error && (
{({ handleSubmit }) => (
<>
<EmailTouchOnRequire emailRequired={emailRequired} />
<form className="RegisterForm" onSubmit={handleSubmit}>
<div className="RegisterForm-column">
<div className="RegisterForm-item">
<Typography color="error">{error}</Typography>
<Field label={t('Common.label.username')} name="userName" component={InputField} autoComplete="username" />
<OnChange name="userName">{onUserNameChange}</OnChange>
</div>
)}
</>
);
}}
<div className="RegisterForm-item">
<Field
label={t('Common.label.password')}
name="password"
type="password"
component={InputField}
autoComplete='new-password'
/>
<OnChange name="password">{onPasswordChange}</OnChange>
</div>
<div className="RegisterForm-item">
<Field
label={t('Common.label.confirmPassword')}
name="passwordConfirm"
type="password"
component={InputField}
autoComplete='new-password'
/>
</div>
<div className="RegisterForm-item">
<Field name="selectedHost" component={KnownHosts} />
<OnChange name="selectedHost">{onHostChange}</OnChange>
</div>
</div>
<div className="RegisterForm-column" >
<div className="RegisterForm-item">
<Field label={t('Common.label.realName')} name="realName" component={InputField} />
</div>
<div className="RegisterForm-item">
<Field label={t('Common.label.email')} name="email" type="email" component={InputField} />
<OnChange name="email">{onEmailChange}</OnChange>
</div>
<div className="RegisterForm-item">
<Field
label={t('Common.label.confirmEmail')}
name="emailConfirm"
type="email"
component={InputField}
/>
</div>
<div className="RegisterForm-item">
<Field label={t('Common.label.country')} name="country" component={CountryDropdown} />
</div>
<Button className="RegisterForm-submit tall" color="primary" variant="contained" type="submit">
{t('RegisterForm.label.register')}
</Button>
</div>
</form>
{error && (
<div className="RegisterForm-item">
<Typography color="error">{error}</Typography>
</div>
)}
</>
)}
</Form >
);
};
interface RegisterFormProps {
onSubmit: any;
}
export default RegisterForm;

View file

@ -1,4 +1,5 @@
import { Form, Field } from 'react-final-form';
import { useCallback } from 'react';
import { Form, Field, FormApi } from 'react-final-form';
import { OnChange } from 'react-final-form-listeners';
import { useTranslation } from 'react-i18next';
@ -6,26 +7,44 @@ import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import { InputField, KnownHosts } from '@app/components';
import type { FormErrors } from '@app/forms';
import { HostDTO } from '@app/services';
import { useRequestPasswordResetForm } from './useRequestPasswordResetForm';
import './RequestPasswordResetForm.css';
const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
export interface RequestPasswordResetFormValues {
userName: string;
email?: string;
selectedHost: HostDTO;
}
interface RequestPasswordResetFormProps {
onSubmit: (values: RequestPasswordResetFormValues) => void;
skipTokenRequest: (userName: string) => void;
}
interface HostChangePayload {
userName?: string;
}
const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }: RequestPasswordResetFormProps) => {
const { t } = useTranslation();
const { errorMessage, setErrorMessage, isMFA, setIsMFA } = useRequestPasswordResetForm();
const handleOnSubmit = ({ userName, email, ...values }) => {
const handleOnSubmit = ({ userName, email, ...values }: RequestPasswordResetFormValues) => {
setErrorMessage(false);
userName = userName?.trim();
email = email?.trim();
onSubmit({ userName, email, ...values });
onSubmit({
...values,
userName: userName?.trim(),
email: email?.trim(),
});
};
const validate = values => {
const errors: any = {};
const validate = (values: Partial<RequestPasswordResetFormValues>): FormErrors<RequestPasswordResetFormValues> => {
const errors: FormErrors<RequestPasswordResetFormValues> = {};
if (!values.userName) {
errors.userName = t('Common.validation.required');
@ -42,50 +61,79 @@ const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
return (
<Form onSubmit={handleOnSubmit} validate={validate}>
{({ handleSubmit, form }) => {
const onHostChange: any = ({ userName }) => {
form.change('userName', userName);
setIsMFA(false);
};
return (
<form className="RequestPasswordResetForm" onSubmit={handleSubmit}>
<div className="RequestPasswordResetForm-items">
<div className="RequestPasswordResetForm-item">
<Field label={t('Common.label.username')} name="userName" component={InputField} autoComplete="username" disabled={isMFA} />
</div>
{isMFA ? (
<div className="RequestPasswordResetForm-item">
<Field label={t('Common.label.email')} name="email" type="email" component={InputField} autoComplete="email" />
<div>{t('RequestPasswordResetForm.mfaEnabled')}</div>
</div>
) : null}
<div className="RequestPasswordResetForm-item selectedHost">
<Field name='selectedHost' component={KnownHosts} disabled={isMFA} />
<OnChange name="selectedHost">{onHostChange}</OnChange>
</div>
{errorMessage && (
<div className="RequestPasswordResetForm-item">
<Typography color="error">{t('RequestPasswordResetForm.error')}</Typography>
</div>
)}
</div>
<Button className="RequestPasswordResetForm-submit rounded tall" color="primary" variant="contained" type="submit">
{t('RequestPasswordResetForm.request')}
</Button>
<div>
<Button color="primary" onClick={() => skipTokenRequest(form.getState().values.userName)}>
{t('RequestPasswordResetForm.skipRequest')}
</Button>
</div>
</form>
);
}}
{({ handleSubmit, form }) => (
<RequestPasswordResetFormBody
handleSubmit={handleSubmit}
form={form}
errorMessage={errorMessage}
isMFA={isMFA}
setIsMFA={setIsMFA}
skipTokenRequest={skipTokenRequest}
/>
)}
</Form>
);
};
interface BodyProps {
handleSubmit: (event?: React.SyntheticEvent) => void;
form: FormApi;
errorMessage: boolean;
isMFA: boolean;
setIsMFA: (v: boolean) => void;
skipTokenRequest: (userName: string) => void;
}
const RequestPasswordResetFormBody = ({
handleSubmit,
form,
errorMessage,
isMFA,
setIsMFA,
skipTokenRequest,
}: BodyProps) => {
const { t } = useTranslation();
const onHostChange = useCallback(({ userName }: HostChangePayload) => {
form.change('userName', userName);
setIsMFA(false);
}, [form, setIsMFA]);
return (
<form className="RequestPasswordResetForm" onSubmit={handleSubmit}>
<div className="RequestPasswordResetForm-items">
<div className="RequestPasswordResetForm-item">
<Field label={t('Common.label.username')} name="userName" component={InputField} autoComplete="username" disabled={isMFA} />
</div>
{isMFA ? (
<div className="RequestPasswordResetForm-item">
<Field label={t('Common.label.email')} name="email" type="email" component={InputField} autoComplete="email" />
<div>{t('RequestPasswordResetForm.mfaEnabled')}</div>
</div>
) : null}
<div className="RequestPasswordResetForm-item selectedHost">
<Field name='selectedHost' component={KnownHosts} disabled={isMFA} />
<OnChange name="selectedHost">{onHostChange}</OnChange>
</div>
{errorMessage && (
<div className="RequestPasswordResetForm-item">
<Typography color="error">{t('RequestPasswordResetForm.error')}</Typography>
</div>
)}
</div>
<Button className="RequestPasswordResetForm-submit rounded tall" color="primary" variant="contained" type="submit">
{t('RequestPasswordResetForm.request')}
</Button>
<div>
<Button color="primary" onClick={() => skipTokenRequest(form.getState().values.userName)}>
{t('RequestPasswordResetForm.skipRequest')}
</Button>
</div>
</form>
);
};
export default RequestPasswordResetForm;

View file

@ -5,17 +5,32 @@ import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import { InputField, KnownHosts } from '@app/components';
import type { FormErrors } from '@app/forms';
import { HostDTO } from '@app/services';
import { useResetPasswordForm } from './useResetPasswordForm';
import './ResetPasswordForm.css';
const ResetPasswordForm = ({ onSubmit, userName }) => {
export interface ResetPasswordFormValues {
userName: string;
token: string;
newPassword: string;
passwordAgain: string;
selectedHost: HostDTO;
}
interface ResetPasswordFormProps {
onSubmit: (values: ResetPasswordFormValues) => void;
userName?: string;
}
const ResetPasswordForm = ({ onSubmit, userName }: ResetPasswordFormProps) => {
const { t } = useTranslation();
const { errorMessage } = useResetPasswordForm();
const validate = values => {
const errors: any = {};
const validate = (values: Partial<ResetPasswordFormValues>): FormErrors<ResetPasswordFormValues> => {
const errors: FormErrors<ResetPasswordFormValues> = {};
if (!values.userName) {
errors.userName = t('Common.validation.required');
@ -42,11 +57,12 @@ const ResetPasswordForm = ({ onSubmit, userName }) => {
return errors;
};
const handleOnSubmit = ({ userName, token, ...values }) => {
userName = userName?.trim();
token = token?.trim();
onSubmit({ userName, token, ...values });
const handleOnSubmit = ({ userName: uName, token, ...values }: ResetPasswordFormValues) => {
onSubmit({
...values,
userName: uName?.trim(),
token: token?.trim(),
});
};
return (
@ -60,7 +76,7 @@ const ResetPasswordForm = ({ onSubmit, userName }) => {
name='userName'
component={InputField}
autoComplete='username'
disabled={!!userName}
InputProps={{ readOnly: Boolean(userName) }}
/>
</div>
<div className='ResetPasswordForm-item'>

View file

@ -0,0 +1,17 @@
{
"SearchForm": {
"label": {
"userName": "Username",
"ipAddress": "IP Address",
"gameName": "Game Name",
"gameId": "GameID",
"message": "Message",
"rooms": "Rooms",
"games": "Games",
"chats": "Chats"
},
"button": {
"search": "Search Logs"
}
}
}

View file

@ -1,6 +1,5 @@
// eslint-disable-next-line
import React from "react";
import { Form, Field } from 'react-final-form';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
@ -10,48 +9,61 @@ import { InputField, CheckboxField } from '@app/components';
import './SearchForm.css';
const SearchForm = ({ onSubmit }) => (
<Form onSubmit={onSubmit}>
{({ handleSubmit }) => (
<Paper className="log-search">
<form className="log-search__form" onSubmit={handleSubmit}>
<div className="log-search__form-item">
<Field label="Username" name="userName" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label="IP Address" name="ipAddress" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label="Game Name" name="gameName" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label="GameID" name="gameId" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label="Message" name="message" component={InputField} />
</div>
<Divider />
<div className="log-search__form-item log-location">
<Field label="Rooms" name="logLocation.room" component={CheckboxField} />
<Field label="Games" name="logLocation.game" component={CheckboxField} />
<Field label="Chats" name="logLocation.chat" component={CheckboxField} />
</div>
<Divider />
<div className="log-search__form-item">
<span>Date Range: Coming Soon</span>
</div>
<Divider />
<div className="log-search__form-item">
<span>Maximum Results: 1000</span>
</div>
<Divider />
<Button className="log-search__form-submit" color="primary" variant="contained" type="submit">
Search Logs
</Button>
</form>
</Paper>
)}
</Form>
);
export interface SearchFormValues {
userName?: string;
ipAddress?: string;
gameName?: string;
gameId?: string;
message?: string;
logLocation?: {
room?: boolean;
game?: boolean;
chat?: boolean;
};
}
interface SearchFormProps {
onSubmit: (values: SearchFormValues) => void;
}
const SearchForm = ({ onSubmit }: SearchFormProps) => {
const { t } = useTranslation();
return (
<Form onSubmit={onSubmit}>
{({ handleSubmit }) => (
<Paper className="log-search">
<form className="log-search__form" onSubmit={handleSubmit}>
<div className="log-search__form-item">
<Field label={t('SearchForm.label.userName')} name="userName" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label={t('SearchForm.label.ipAddress')} name="ipAddress" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label={t('SearchForm.label.gameName')} name="gameName" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label={t('SearchForm.label.gameId')} name="gameId" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label={t('SearchForm.label.message')} name="message" component={InputField} />
</div>
<Divider />
<div className="log-search__form-item log-location">
<Field label={t('SearchForm.label.rooms')} name="logLocation.room" component={CheckboxField} />
<Field label={t('SearchForm.label.games')} name="logLocation.game" component={CheckboxField} />
<Field label={t('SearchForm.label.chats')} name="logLocation.chat" component={CheckboxField} />
</div>
<Divider />
<Button className="log-search__form-submit" color="primary" variant="contained" type="submit">
{t('SearchForm.button.search')}
</Button>
</form>
</Paper>
)}
</Form>
);
};
export default SearchForm;

Some files were not shown because too many files have changed in this diff Show more