mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-04-27 07:48:01 -07:00
Comprehensive review changes
This commit is contained in:
parent
3aa8c654cc
commit
6074d9d6e4
143 changed files with 2661 additions and 1535 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 0–10). 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 0–10). 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 });
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { ServerSelectors, useAppSelector } from '@app/store';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { ServerSelectors, useAppSelector } from '@app/store';
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
.input-action__item {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.input-action__item > div {
|
||||
margin: 0;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
.LanguageDropdown {
|
||||
}
|
||||
|
||||
.LanguageDropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
.three-pane-layout .grid-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.three-pane-layout .grid-main__top {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
3
webclient/src/components/fieldTypes.ts
Normal file
3
webclient/src/components/fieldTypes.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import type { FieldRenderProps } from 'react-final-form';
|
||||
|
||||
export type FinalFormFieldProps<T, E extends HTMLElement = HTMLElement> = FieldRenderProps<T, E>;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
24
webclient/src/containers/Account/AddUserForm.tsx
Normal file
24
webclient/src/containers/Account/AddUserForm.tsx
Normal 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;
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { App } from '@app/types';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ function Decks() {
|
|||
return (
|
||||
<Layout>
|
||||
<AuthGuard />
|
||||
<span>"Decks"</span>
|
||||
<span>Decks</span>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
20
webclient/src/containers/Logs/Logs.i18n.json
Normal file
20
webclient/src/containers/Logs/Logs.i18n.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
66
webclient/src/containers/Player/Player.css
Normal file
66
webclient/src/containers/Player/Player.css
Normal 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);
|
||||
}
|
||||
33
webclient/src/containers/Player/Player.i18n.json
Normal file
33
webclient/src/containers/Player/Player.i18n.json
Normal 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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
84
webclient/src/containers/Player/usePlayer.ts
Normal file
84
webclient/src/containers/Player/usePlayer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { useOpenGames } from './useOpenGames';
|
|||
import './OpenGames.css';
|
||||
|
||||
interface OpenGamesProps {
|
||||
room: { info: { roomId: number } };
|
||||
room: Enriched.Room;
|
||||
onActivateGame?: (gameId: number) => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
.rooms {
|
||||
}
|
||||
|
||||
.rooms-header,
|
||||
.room {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
55
webclient/src/dialogs/AuthDialogShell/AuthDialogShell.tsx
Normal file
55
webclient/src/dialogs/AuthDialogShell/AuthDialogShell.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
.dialog-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
25
webclient/src/forms/CardImportForm/CardImportForm.i18n.json
Normal file
25
webclient/src/forms/CardImportForm/CardImportForm.i18n.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) }
|
||||
|
|
|
|||
|
|
@ -18,3 +18,9 @@
|
|||
.loginForm-submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loginForm-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"forgot": "Forgot Password",
|
||||
"login": "Login",
|
||||
"savePassword": "Save Password",
|
||||
"savedPassword": "Saved Password"
|
||||
"savedPassword": "* Saved Password *"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
17
webclient/src/forms/SearchForm/SearchForm.i18n.json
Normal file
17
webclient/src/forms/SearchForm/SearchForm.i18n.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue