mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-07-01 11:03:54 -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);
|
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();
|
expect(mock.close).toHaveBeenCalled();
|
||||||
|
// Never-opened sockets bypass reconnect and land on DISCONNECTED directly.
|
||||||
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
|
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -111,12 +116,15 @@ describe('connection lifecycle', () => {
|
||||||
|
|
||||||
const mock = getMockWebSocket();
|
const mock = getMockWebSocket();
|
||||||
getWebClient().disconnect();
|
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(mock.close).toHaveBeenCalled();
|
||||||
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
|
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();
|
connectAndHandshake();
|
||||||
|
|
||||||
// A login command is now pending (sent during handshake)
|
// A login command is now pending (sent during handshake)
|
||||||
|
|
@ -127,6 +135,8 @@ describe('connection lifecycle', () => {
|
||||||
mock.readyState = 3;
|
mock.readyState = 3;
|
||||||
mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent);
|
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'>;
|
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> {
|
export class AuthenticationRequestImpl implements WebsocketTypes.IAuthenticationRequest<AppAuthRequestOverrides> {
|
||||||
login(options: Omit<WebsocketTypes.LoginConnectOptions, 'reason'>): void {
|
login(options: Omit<WebsocketTypes.LoginConnectOptions, 'reason'>): void {
|
||||||
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.LOGIN });
|
beginConnect(options, WebsocketTypes.WebSocketConnectReason.LOGIN);
|
||||||
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
|
|
||||||
WebClient.instance.connect({ host: options.host, port: options.port });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
testConnection(options: Omit<WebsocketTypes.TestConnectionOptions, 'reason'>): void {
|
testConnection(options: Omit<WebsocketTypes.TestConnectionOptions, 'reason'>): void {
|
||||||
|
|
@ -27,33 +36,23 @@ export class AuthenticationRequestImpl implements WebsocketTypes.IAuthentication
|
||||||
}
|
}
|
||||||
|
|
||||||
register(options: Omit<WebsocketTypes.RegisterConnectOptions, 'reason'>): void {
|
register(options: Omit<WebsocketTypes.RegisterConnectOptions, 'reason'>): void {
|
||||||
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.REGISTER });
|
beginConnect(options, WebsocketTypes.WebSocketConnectReason.REGISTER);
|
||||||
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
|
|
||||||
WebClient.instance.connect({ host: options.host, port: options.port });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
activateAccount(options: Omit<WebsocketTypes.ActivateConnectOptions, 'reason'>): void {
|
activateAccount(options: Omit<WebsocketTypes.ActivateConnectOptions, 'reason'>): void {
|
||||||
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT });
|
beginConnect(options, WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT);
|
||||||
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
|
|
||||||
WebClient.instance.connect({ host: options.host, port: options.port });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetPasswordRequest(options: Omit<WebsocketTypes.PasswordResetRequestConnectOptions, 'reason'>): void {
|
resetPasswordRequest(options: Omit<WebsocketTypes.PasswordResetRequestConnectOptions, 'reason'>): void {
|
||||||
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST });
|
beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST);
|
||||||
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
|
|
||||||
WebClient.instance.connect({ host: options.host, port: options.port });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetPasswordChallenge(options: Omit<WebsocketTypes.PasswordResetChallengeConnectOptions, 'reason'>): void {
|
resetPasswordChallenge(options: Omit<WebsocketTypes.PasswordResetChallengeConnectOptions, 'reason'>): void {
|
||||||
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE });
|
beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE);
|
||||||
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
|
|
||||||
WebClient.instance.connect({ host: options.host, port: options.port });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetPassword(options: Omit<WebsocketTypes.PasswordResetConnectOptions, 'reason'>): void {
|
resetPassword(options: Omit<WebsocketTypes.PasswordResetConnectOptions, 'reason'>): void {
|
||||||
setPendingOptions({ ...options, reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET });
|
beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET);
|
||||||
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, 'Connecting...');
|
|
||||||
WebClient.instance.connect({ host: options.host, port: options.port });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
"language": "English",
|
"language": "English",
|
||||||
"disconnect": "Disconnect",
|
"disconnect": "Disconnect",
|
||||||
"label": {
|
"label": {
|
||||||
|
"confirmEmail": "Confirm Email",
|
||||||
"confirmPassword": "Confirm Password",
|
"confirmPassword": "Confirm Password",
|
||||||
"confirmSure": "Are you sure?",
|
"confirmSure": "Are you sure?",
|
||||||
"country": "Country",
|
"country": "Country",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"username": "Username"
|
"username": "Username"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
|
"emailsMustMatch": "Emails don't match",
|
||||||
"minChars": "Minimum of {count} {count, plural, one {character} other {characters}} required",
|
"minChars": "Minimum of {count} {count, plural, one {character} other {characters}} required",
|
||||||
"passwordsMustMatch": "Passwords don't match",
|
"passwordsMustMatch": "Passwords don't match",
|
||||||
"required": "Required"
|
"required": "Required"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
// eslint-disable-next-line
|
|
||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { CardDTO } from '@app/services';
|
import { CardDTO } from '@app/services';
|
||||||
|
|
||||||
import './Card.css';
|
import './Card.css';
|
||||||
|
|
@ -10,11 +7,13 @@ interface CardProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Card = ({ card }: CardProps) => {
|
const Card = ({ card }: CardProps) => {
|
||||||
const src = `https://api.scryfall.com/cards/${card?.identifiers?.scryfallId}?format=image`;
|
if (!card) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return card && (
|
const src = `https://api.scryfall.com/cards/${card.identifiers?.scryfallId}?format=image`;
|
||||||
<img className="card" src={src} alt={card?.name} />
|
|
||||||
);
|
return <img className="card" src={src} alt={card.name} />;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Card;
|
export default Card;
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ const CardDetails = ({ card }: CardProps) => {
|
||||||
(!card.power && !card.toughness) ? null : (
|
(!card.power && !card.toughness) ? null : (
|
||||||
<div className='cardDetails-attribute'>
|
<div className='cardDetails-attribute'>
|
||||||
<span className='cardDetails-attribute__label'>P/T:</span>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,28 @@
|
||||||
import React from 'react';
|
import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
|
||||||
import Checkbox from '@mui/material/Checkbox';
|
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
|
||||||
const CheckboxField = (props) => {
|
import type { FinalFormFieldProps } from '../fieldTypes';
|
||||||
const { input: { value, onChange }, label, ...args } = props;
|
|
||||||
|
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 (
|
return (
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
className="checkbox-field"
|
className="checkbox-field"
|
||||||
label={label}
|
label={label ?? ''}
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
{ ...args }
|
{...args}
|
||||||
className="checkbox-field__box"
|
className="checkbox-field__box"
|
||||||
checked={!!value}
|
name={name}
|
||||||
onChange={(e, checked) => onChange(checked)}
|
checked={Boolean(value)}
|
||||||
|
onChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onFocus={onFocus}
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Select, MenuItem } from '@mui/material';
|
import { Select, MenuItem } from '@mui/material';
|
||||||
import FormControl from '@mui/material/FormControl';
|
import FormControl from '@mui/material/FormControl';
|
||||||
import InputLabel from '@mui/material/InputLabel';
|
import InputLabel from '@mui/material/InputLabel';
|
||||||
|
|
@ -8,49 +7,48 @@ import { useLocaleSort } from '@app/hooks';
|
||||||
import { Images } from '@app/images';
|
import { Images } from '@app/images';
|
||||||
import { App } from '@app/types';
|
import { App } from '@app/types';
|
||||||
|
|
||||||
|
import type { FinalFormFieldProps } from '../fieldTypes';
|
||||||
|
|
||||||
import './CountryDropdown.css';
|
import './CountryDropdown.css';
|
||||||
|
|
||||||
const CountryDropdown = ({ input: { onChange } }) => {
|
type CountryDropdownProps = FinalFormFieldProps<string, HTMLElement>;
|
||||||
const [value, setValue] = useState('');
|
|
||||||
|
const CountryDropdown = ({ input }: CountryDropdownProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const currentValue = (input.value as string | undefined) ?? '';
|
||||||
|
|
||||||
useEffect(() => onChange(value), [value]);
|
const translateCountry = (country: string) => t(`Common.countries.${country}`);
|
||||||
|
|
||||||
const translateCountry = country => t(`Common.countries.${country}`);
|
|
||||||
const sortedCountries = useLocaleSort(App.countryCodes, translateCountry);
|
const sortedCountries = useLocaleSort(App.countryCodes, translateCountry);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormControl size='small' variant='outlined' className='CountryDropdown'>
|
<FormControl size="small" variant="outlined" className="CountryDropdown">
|
||||||
<InputLabel id='CountryDropdown-select'>Country</InputLabel>
|
<InputLabel id="CountryDropdown-label">Country</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
id='CountryDropdown-select'
|
id="CountryDropdown-select"
|
||||||
labelId='CountryDropdown-label'
|
labelId="CountryDropdown-label"
|
||||||
label='Country'
|
label="Country"
|
||||||
margin='dense'
|
margin="dense"
|
||||||
value={value}
|
fullWidth
|
||||||
fullWidth={true}
|
{...input}
|
||||||
onChange={e => setValue(e.target.value as string)}
|
value={currentValue}
|
||||||
>
|
>
|
||||||
<MenuItem value={''} key={-1}>
|
<MenuItem value="" key="none">
|
||||||
<div className="CountryDropdown-item">
|
<div className="CountryDropdown-item">
|
||||||
<span className="CountryDropdown-item__label">None</span>
|
<span className="CountryDropdown-item__label">None</span>
|
||||||
</div>
|
</div>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
||||||
{
|
{sortedCountries.map(country => (
|
||||||
sortedCountries.map((country, index:number) => (
|
<MenuItem value={country} key={country}>
|
||||||
<MenuItem value={country} key={index}>
|
<div className="CountryDropdown-item">
|
||||||
<div className="CountryDropdown-item">
|
<img className="CountryDropdown-item__image" src={Images.Countries[country.toLowerCase()]} />
|
||||||
<img className="CountryDropdown-item__image" src={Images.Countries[country.toLowerCase()]} />
|
<span className="CountryDropdown-item__label">{translateCountry(country)}</span>
|
||||||
<span className="CountryDropdown-item__label">{translateCountry(country)}</span>
|
</div>
|
||||||
</div>
|
</MenuItem>
|
||||||
</MenuItem>
|
))}
|
||||||
))
|
|
||||||
}
|
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CountryDropdown;
|
export default CountryDropdown;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
import type { Data } from '@app/types';
|
import type { Data } from '@app/types';
|
||||||
import { cx } from '@app/utils';
|
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 name = players?.[m.playerId]?.properties.userInfo?.name ?? `p${m.playerId}`;
|
||||||
const lineClass = isEvent ? 'game-log__line game-log__line--event' : 'game-log__line';
|
const lineClass = isEvent ? 'game-log__line game-log__line--event' : 'game-log__line';
|
||||||
return (
|
return (
|
||||||
<div key={idx} className={lineClass}>
|
<div key={`${m.timeReceived}-${idx}`} className={lineClass}>
|
||||||
{!isEvent && <span className="game-log__author">{name}:</span>}
|
{!isEvent && <span className="game-log__author">{name}:</span>}
|
||||||
<span className="game-log__text">{m.message}</span>
|
<span className="game-log__text">{m.message}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@ import { useMemo, useState } from 'react';
|
||||||
import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks';
|
import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks';
|
||||||
import { GameSelectors, useAppSelector } from '@app/store';
|
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 {
|
export interface TurnControlsOpponent {
|
||||||
playerId: number;
|
playerId: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -119,11 +125,11 @@ export function useTurnControls({
|
||||||
if (!canAdvance || !hasLiveGame) {
|
if (!canAdvance || !hasLiveGame) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Desktop wraps at 11 → 0 (the Phase enum is 0–10). When no phase is
|
// Desktop wraps at PHASE_COUNT → 0 (the Phase enum is 0–10). When no phase
|
||||||
// active yet (activePhase < 0 during the pre-game lobby), advance to
|
// is active yet (activePhase < 0 during the pre-game lobby), advance to
|
||||||
// Untap (0).
|
// Untap (0).
|
||||||
const current = game.activePhase;
|
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 });
|
webClient.request.game.setActivePhase(gameId, { phase: next });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import React from 'react';
|
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { ServerSelectors, useAppSelector } from '@app/store';
|
import { ServerSelectors, useAppSelector } from '@app/store';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import React from 'react';
|
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { ServerSelectors, useAppSelector } from '@app/store';
|
import { ServerSelectors, useAppSelector } from '@app/store';
|
||||||
|
|
|
||||||
|
|
@ -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 Button from '@mui/material/Button';
|
||||||
|
|
||||||
import { InputField } from '..';
|
import { InputField } from '..';
|
||||||
|
|
||||||
import './InputAction.css';
|
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">
|
||||||
<div className="input-action__item">
|
<div className="input-action__item">
|
||||||
<Field label={label} name={name} component={InputField} validate={validate} />
|
<Field label={label} name={name} component={InputField} validate={validate} />
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,57 @@
|
||||||
import React from 'react';
|
|
||||||
import { styled } from '@mui/material/styles';
|
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 ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
|
||||||
|
|
||||||
|
import type { FinalFormFieldProps } from '../fieldTypes';
|
||||||
|
|
||||||
import './InputField.css';
|
import './InputField.css';
|
||||||
|
|
||||||
const PREFIX = 'InputField';
|
const PREFIX = 'InputField';
|
||||||
|
|
||||||
const classes = {
|
const classes = {
|
||||||
root: `${PREFIX}-root`
|
root: `${PREFIX}-root`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const Root = styled('div')(({ theme }) => ({
|
const Root = styled('div')(({ theme }) => ({
|
||||||
[`&.${classes.root}`]: {
|
[`&.${classes.root}`]: {
|
||||||
'& .InputField-error': {
|
'& .InputField-error': {
|
||||||
color: theme.palette.error.main
|
color: theme.palette.error.main,
|
||||||
},
|
},
|
||||||
|
|
||||||
'& .InputField-warning': {
|
'& .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;
|
const { touched, error, warning } = meta;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Root className={'InputField ' + classes.root}>
|
<Root className={`InputField ${classes.root}`}>
|
||||||
{ touched && (
|
{touched && (
|
||||||
<div className="InputField-validation">
|
<div className="InputField-validation">
|
||||||
{
|
{(error &&
|
||||||
(error &&
|
<div className="InputField-error">
|
||||||
<div className="InputField-error">
|
{error}
|
||||||
{error}
|
<ErrorOutlinedIcon style={{ fontSize: 'small', fontWeight: 'bold' }} />
|
||||||
<ErrorOutlinedIcon style={{ fontSize: 'small', fontWeight: 'bold' }} />
|
</div>
|
||||||
</div>
|
) || (warning && <div className="InputField-warning">{warning}</div>)}
|
||||||
) ||
|
|
||||||
|
|
||||||
(warning && <div className="InputField-warning">{warning}</div>)
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
) }
|
)}
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
autoComplete='off'
|
autoComplete="off"
|
||||||
{ ...input }
|
{...input}
|
||||||
{ ...args }
|
{...args}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
margin="dense"
|
margin="dense"
|
||||||
size="small"
|
size="small"
|
||||||
fullWidth={true}
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</Root>
|
</Root>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 Button from '@mui/material/Button';
|
||||||
import FormControl from '@mui/material/FormControl';
|
import FormControl from '@mui/material/FormControl';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
|
@ -14,8 +14,8 @@ import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
|
||||||
|
|
||||||
import { KnownHostDialog } from '@app/dialogs';
|
import { KnownHostDialog } from '@app/dialogs';
|
||||||
import { getHostPort, HostDTO } from '@app/services';
|
import { getHostPort, HostDTO } from '@app/services';
|
||||||
import Toast from '../Toast/Toast';
|
|
||||||
|
|
||||||
|
import type { FinalFormFieldProps } from '../fieldTypes';
|
||||||
import { TestConnection, useKnownHostsComponent } from './useKnownHostsComponent';
|
import { TestConnection, useKnownHostsComponent } from './useKnownHostsComponent';
|
||||||
|
|
||||||
import './KnownHosts.css';
|
import './KnownHosts.css';
|
||||||
|
|
@ -50,9 +50,11 @@ const Root = styled('div')(({ theme }) => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const KnownHosts = (props: any) => {
|
type KnownHostsProps = FinalFormFieldProps<HostDTO | undefined, HTMLElement> & {
|
||||||
const { input, meta, disabled } = props;
|
disabled?: boolean;
|
||||||
const onChange: (value: HostDTO) => void = input.onChange;
|
};
|
||||||
|
|
||||||
|
const KnownHosts = ({ input, meta, disabled }: KnownHostsProps) => {
|
||||||
const { touched, error, warning } = meta;
|
const { touched, error, warning } = meta;
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -61,22 +63,25 @@ const KnownHosts = (props: any) => {
|
||||||
selectedHost,
|
selectedHost,
|
||||||
testingConnection,
|
testingConnection,
|
||||||
dialogState,
|
dialogState,
|
||||||
showCreateToast,
|
|
||||||
showDeleteToast,
|
|
||||||
showEditToast,
|
|
||||||
setShowCreateToast,
|
|
||||||
setShowDeleteToast,
|
|
||||||
setShowEditToast,
|
|
||||||
onPick,
|
onPick,
|
||||||
openAddKnownHostDialog,
|
openAddKnownHostDialog,
|
||||||
openEditKnownHostDialog,
|
openEditKnownHostDialog,
|
||||||
closeKnownHostDialog,
|
closeKnownHostDialog,
|
||||||
handleDialogRemove,
|
handleDialogRemove,
|
||||||
handleDialogSubmit,
|
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 (
|
return (
|
||||||
<Root className={'KnownHosts ' + classes.root}>
|
<Root className={`KnownHosts ${classes.root}`}>
|
||||||
<FormControl className="KnownHosts-form" size="small" variant="outlined">
|
<FormControl className="KnownHosts-form" size="small" variant="outlined">
|
||||||
{touched && (
|
{touched && (
|
||||||
<div className="KnownHosts-validation">
|
<div className="KnownHosts-validation">
|
||||||
|
|
@ -97,24 +102,24 @@ const KnownHosts = (props: any) => {
|
||||||
label="Host"
|
label="Host"
|
||||||
margin="dense"
|
margin="dense"
|
||||||
name="host"
|
name="host"
|
||||||
value={selectedHost ?? ''}
|
value={selectedId}
|
||||||
fullWidth={true}
|
fullWidth
|
||||||
onChange={(e) => onPick(e.target.value as unknown as HostDTO)}
|
onChange={handleSelectChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Button value={selectedHost} onClick={openAddKnownHostDialog}>
|
<Button onClick={openAddKnownHostDialog}>
|
||||||
<span>{t('KnownHosts.add')}</span>
|
<span>{t('KnownHosts.add')}</span>
|
||||||
<AddIcon fontSize="small" color="primary" />
|
<AddIcon fontSize="small" color="primary" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{hosts.map((host, index) => {
|
{hosts.map((host) => {
|
||||||
const hostPort = getHostPort(host);
|
const hostPort = getHostPort(host);
|
||||||
|
|
||||||
return (
|
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">
|
||||||
<div className="KnownHosts-item__wrapper">
|
<div className="KnownHosts-item__wrapper">
|
||||||
<div className={'KnownHosts-item__status ' + testingConnection}>
|
<div className={`KnownHosts-item__status ${testingConnection ?? ''}`}>
|
||||||
{testingConnection === TestConnection.FAILED ? (
|
{testingConnection === TestConnection.FAILED ? (
|
||||||
<PortableWifiOffIcon fontSize="small" />
|
<PortableWifiOffIcon fontSize="small" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -151,20 +156,11 @@ const KnownHosts = (props: any) => {
|
||||||
|
|
||||||
<KnownHostDialog
|
<KnownHostDialog
|
||||||
isOpen={dialogState.open}
|
isOpen={dialogState.open}
|
||||||
host={dialogState.edit}
|
host={dialogState.edit ?? undefined}
|
||||||
onRemove={handleDialogRemove}
|
onRemove={handleDialogRemove}
|
||||||
onSubmit={handleDialogSubmit}
|
onSubmit={handleDialogSubmit}
|
||||||
handleClose={closeKnownHostDialog}
|
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>
|
</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 { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
||||||
import { getHostPort, HostDTO } from '@app/services';
|
import { getHostPort, HostDTO } from '@app/services';
|
||||||
import { ServerTypes } from '@app/store';
|
import { ServerTypes } from '@app/store';
|
||||||
|
|
@ -16,13 +18,7 @@ export interface KnownHostsComponent {
|
||||||
selectedHost: App.Host | undefined;
|
selectedHost: App.Host | undefined;
|
||||||
testingConnection: TestConnection | null;
|
testingConnection: TestConnection | null;
|
||||||
dialogState: { open: boolean; edit: HostDTO | null };
|
dialogState: { open: boolean; edit: HostDTO | null };
|
||||||
showCreateToast: boolean;
|
onPick: (id: number) => Promise<void>;
|
||||||
showDeleteToast: boolean;
|
|
||||||
showEditToast: boolean;
|
|
||||||
setShowCreateToast: (v: boolean) => void;
|
|
||||||
setShowDeleteToast: (v: boolean) => void;
|
|
||||||
setShowEditToast: (v: boolean) => void;
|
|
||||||
onPick: (host: HostDTO) => Promise<void>;
|
|
||||||
openAddKnownHostDialog: () => void;
|
openAddKnownHostDialog: () => void;
|
||||||
openEditKnownHostDialog: (host: HostDTO) => void;
|
openEditKnownHostDialog: (host: HostDTO) => void;
|
||||||
closeKnownHostDialog: () => void;
|
closeKnownHostDialog: () => void;
|
||||||
|
|
@ -39,11 +35,20 @@ export interface UseKnownHostsComponentArgs {
|
||||||
onChange: (value: HostDTO) => void;
|
onChange: (value: HostDTO) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToastMode = 'created' | 'deleted' | 'edited';
|
||||||
|
|
||||||
export function useKnownHostsComponent({
|
export function useKnownHostsComponent({
|
||||||
onChange,
|
onChange,
|
||||||
}: UseKnownHostsComponentArgs): KnownHostsComponent {
|
}: UseKnownHostsComponentArgs): KnownHostsComponent {
|
||||||
const webClient = useWebClient();
|
const webClient = useWebClient();
|
||||||
const knownHosts = useKnownHosts();
|
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 }>({
|
const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({
|
||||||
open: false,
|
open: false,
|
||||||
|
|
@ -51,16 +56,16 @@ export function useKnownHostsComponent({
|
||||||
});
|
});
|
||||||
|
|
||||||
const [testingConnection, setTestingConnection] = useState<TestConnection | null>(null);
|
const [testingConnection, setTestingConnection] = useState<TestConnection | null>(null);
|
||||||
|
// Tracks the host currently awaiting a testConnection response. If null when a
|
||||||
const [showCreateToast, setShowCreateToast] = useState(false);
|
// response arrives, the caller has moved on — ignore the stale reply.
|
||||||
const [showDeleteToast, setShowDeleteToast] = useState(false);
|
const pendingTestRef = useRef<HostDTO | null>(null);
|
||||||
const [showEditToast, setShowEditToast] = useState(false);
|
|
||||||
|
|
||||||
const selectedHost =
|
const selectedHost =
|
||||||
knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined;
|
knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined;
|
||||||
const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : [];
|
const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : [];
|
||||||
|
|
||||||
const testConnection = (host: HostDTO) => {
|
const testConnection = (host: HostDTO) => {
|
||||||
|
pendingTestRef.current = host;
|
||||||
setTestingConnection(TestConnection.TESTING);
|
setTestingConnection(TestConnection.TESTING);
|
||||||
webClient.request.authentication.testConnection({ ...getHostPort(host) });
|
webClient.request.authentication.testConnection({ ...getHostPort(host) });
|
||||||
};
|
};
|
||||||
|
|
@ -73,28 +78,37 @@ export function useKnownHostsComponent({
|
||||||
testConnection(selectedHost);
|
testConnection(selectedHost);
|
||||||
}, [selectedHost]);
|
}, [selectedHost]);
|
||||||
|
|
||||||
useReduxEffect(
|
useReduxEffect(() => {
|
||||||
() => {
|
if (!pendingTestRef.current) {
|
||||||
setTestingConnection(TestConnection.SUCCESS);
|
return;
|
||||||
},
|
}
|
||||||
ServerTypes.TEST_CONNECTION_SUCCESSFUL,
|
setTestingConnection(TestConnection.SUCCESS);
|
||||||
[],
|
pendingTestRef.current = null;
|
||||||
);
|
}, ServerTypes.TEST_CONNECTION_SUCCESSFUL, []);
|
||||||
|
|
||||||
useReduxEffect(
|
useReduxEffect(() => {
|
||||||
() => {
|
if (!pendingTestRef.current) {
|
||||||
setTestingConnection(TestConnection.FAILED);
|
return;
|
||||||
},
|
}
|
||||||
ServerTypes.TEST_CONNECTION_FAILED,
|
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) {
|
if (knownHosts.status !== LoadingState.READY) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const host = knownHosts.value?.hosts.find((h) => h.id === id);
|
||||||
|
if (!host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onChange(host);
|
onChange(host);
|
||||||
await knownHosts.select(host.id!);
|
await knownHosts.select(id);
|
||||||
testConnection(host);
|
testConnection(host);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -116,7 +130,7 @@ export function useKnownHostsComponent({
|
||||||
}
|
}
|
||||||
await knownHosts.remove(id);
|
await knownHosts.remove(id);
|
||||||
closeKnownHostDialog();
|
closeKnownHostDialog();
|
||||||
setShowDeleteToast(true);
|
fireToast('deleted');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDialogSubmit = async ({
|
const handleDialogSubmit = async ({
|
||||||
|
|
@ -136,11 +150,11 @@ export function useKnownHostsComponent({
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
await knownHosts.update(id, { name, host, port });
|
await knownHosts.update(id, { name, host, port });
|
||||||
setShowEditToast(true);
|
fireToast('edited');
|
||||||
} else {
|
} else {
|
||||||
const newHost: App.Host = { name, host, port, editable: true };
|
const newHost: App.Host = { name, host, port, editable: true };
|
||||||
await knownHosts.add(newHost);
|
await knownHosts.add(newHost);
|
||||||
setShowCreateToast(true);
|
fireToast('created');
|
||||||
}
|
}
|
||||||
|
|
||||||
closeKnownHostDialog();
|
closeKnownHostDialog();
|
||||||
|
|
@ -151,12 +165,6 @@ export function useKnownHostsComponent({
|
||||||
selectedHost,
|
selectedHost,
|
||||||
testingConnection,
|
testingConnection,
|
||||||
dialogState,
|
dialogState,
|
||||||
showCreateToast,
|
|
||||||
showDeleteToast,
|
|
||||||
showEditToast,
|
|
||||||
setShowCreateToast,
|
|
||||||
setShowDeleteToast,
|
|
||||||
setShowEditToast,
|
|
||||||
onPick,
|
onPick,
|
||||||
openAddKnownHostDialog,
|
openAddKnownHostDialog,
|
||||||
openEditKnownHostDialog,
|
openEditKnownHostDialog,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
.LanguageDropdown {
|
|
||||||
}
|
|
||||||
|
|
||||||
.LanguageDropdown-item {
|
.LanguageDropdown-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
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 FormControl from '@mui/material/FormControl';
|
||||||
|
|
||||||
import { Images } from '@app/images';
|
import { Images } from '@app/images';
|
||||||
|
|
@ -11,48 +9,43 @@ import './LanguageDropdown.css';
|
||||||
|
|
||||||
const LanguageDropdown = () => {
|
const LanguageDropdown = () => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
// i18next `resolvedLanguage` is undefined until a registered resource matches;
|
const currentLanguage = i18n.resolvedLanguage ?? i18n.language ?? '';
|
||||||
// MUI Select requires a concrete, in-range value.
|
|
||||||
const [language, setLanguage] = useState(i18n.resolvedLanguage ?? i18n.language ?? '');
|
|
||||||
|
|
||||||
useEffect(() => {
|
const onLanguageChange = (event: SelectChangeEvent) => {
|
||||||
if (language !== i18n.resolvedLanguage) {
|
const next = event.target.value as App.Language;
|
||||||
i18n.changeLanguage(language);
|
if (next !== currentLanguage) {
|
||||||
|
void i18n.changeLanguage(next);
|
||||||
}
|
}
|
||||||
}, [language]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormControl size='small' variant='outlined' className='LanguageDropdown'>
|
<FormControl size="small" variant="outlined" className="LanguageDropdown">
|
||||||
<Select
|
<Select
|
||||||
id='LanguageDropdown-select'
|
id="LanguageDropdown-select"
|
||||||
margin='dense'
|
margin="dense"
|
||||||
value={language}
|
value={currentLanguage}
|
||||||
fullWidth={true}
|
fullWidth
|
||||||
onChange={e => setLanguage(e.target.value as App.Language)}
|
onChange={onLanguageChange}
|
||||||
>
|
>
|
||||||
{
|
{Object.keys(App.Language).map((lang) => {
|
||||||
Object.keys(App.Language).map((lang) => {
|
const country = App.LanguageCountry[lang];
|
||||||
const country = App.LanguageCountry[lang];
|
const nativeName = App.LanguageNative[lang];
|
||||||
|
const translatedName = t(`Common.languages.${lang}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem value={lang} key={lang}>
|
<MenuItem value={lang} key={lang}>
|
||||||
<div className="LanguageDropdown-item">
|
<div className="LanguageDropdown-item">
|
||||||
<img className="LanguageDropdown-item__image" src={Images.Countries[country]} />
|
<img className="LanguageDropdown-item__image" src={Images.Countries[country]} />
|
||||||
<span className="LanguageDropdown-item__label">
|
<span className="LanguageDropdown-item__label">
|
||||||
{App.LanguageNative[lang]} {
|
{nativeName} {nativeName !== translatedName && <>({translatedName})</>}
|
||||||
App.LanguageNative[lang] !== t(`Common.languages.${lang}`) && (
|
</span>
|
||||||
<>({ t(`Common.languages.${lang}`) })</>
|
</div>
|
||||||
)
|
</MenuItem>
|
||||||
}
|
);
|
||||||
</span>
|
})}
|
||||||
</div>
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LanguageDropdown;
|
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 } =
|
const { card, token, anchorEl, open, handlePopoverOpen, handlePopoverClose } =
|
||||||
useCardCallout(name);
|
useCardCallout(name);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,15 @@ import CardCallout from './CardCallout';
|
||||||
import { useParsedMessage } from './useMessage';
|
import { useParsedMessage } from './useMessage';
|
||||||
import './Message.css';
|
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'>
|
||||||
<div className='message__detail'>
|
<div className='message__detail'>
|
||||||
<ParsedMessage message={message} />
|
<ParsedMessage message={message} />
|
||||||
|
|
@ -15,7 +23,11 @@ const Message = ({ message: { message } }) => (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ParsedMessage = ({ message }) => {
|
interface ParsedMessageProps {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ParsedMessage = ({ message }: ParsedMessageProps) => {
|
||||||
const { name, chunks } = useParsedMessage(message, parseChunks);
|
const { name, chunks } = useParsedMessage(message, parseChunks);
|
||||||
|
|
||||||
return (
|
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 })}>
|
<NavLink className="link" to={generatePath(App.RouteEnum.PLAYER, { name })}>
|
||||||
{label}
|
{label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
@ -69,7 +86,7 @@ function parseMentionChunk(chunk: string): ReactNode {
|
||||||
|
|
||||||
if (mention) {
|
if (mention) {
|
||||||
const name = mention[0].substr(1);
|
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;
|
return mentionChunk;
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,25 @@
|
||||||
import { useEffect, useState, type ReactNode } from 'react';
|
import { useMemo, type ReactNode } from 'react';
|
||||||
|
|
||||||
import { App } from '@app/types';
|
import { App } from '@app/types';
|
||||||
|
|
||||||
export interface ParsedMessage {
|
export interface ParsedMessage {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
chunks: ReactNode[] | null;
|
chunks: ReactNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChunkParser = (chunk: string, index: number) => 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 {
|
export function useParsedMessage(message: string, parseChunk: ChunkParser): ParsedMessage {
|
||||||
const [chunks, setChunks] = useState<ReactNode[] | null>(null);
|
return useMemo<ParsedMessage>(() => {
|
||||||
const [name, setName] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const match = message.match(App.MESSAGE_SENDER_REGEX);
|
const match = message.match(App.MESSAGE_SENDER_REGEX);
|
||||||
if (match) {
|
const name = match ? match[1] : null;
|
||||||
setName(match[1]);
|
return {
|
||||||
}
|
name,
|
||||||
setChunks(parseMessage(message, parseChunk));
|
chunks: parseMessage(message, parseChunk),
|
||||||
|
};
|
||||||
}, [message, parseChunk]);
|
}, [message, parseChunk]);
|
||||||
|
|
||||||
return { name, chunks };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseMessage(message: string, parseChunk: ChunkParser): ReactNode[] {
|
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 }) => {
|
interface ScrollToBottomOnChangesProps {
|
||||||
const messagesEndRef = useRef(null);
|
content: ReactNode;
|
||||||
|
changes: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const ScrollToBottomOnChanges = ({ content, changes }: ScrollToBottomOnChangesProps) => {
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(scrollToBottom, [changes]);
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
const styling = {
|
}, [changes]);
|
||||||
height: '100%'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styling}>
|
<div style={{ height: '100%' }}>
|
||||||
{content}
|
{content}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ScrollToBottomOnChanges;
|
export default ScrollToBottomOnChanges;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,29 @@
|
||||||
import React from 'react';
|
|
||||||
import FormControl from '@mui/material/FormControl';
|
import FormControl from '@mui/material/FormControl';
|
||||||
import InputLabel from '@mui/material/InputLabel';
|
import InputLabel from '@mui/material/InputLabel';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
import Select from '@mui/material/Select';
|
import Select from '@mui/material/Select';
|
||||||
|
|
||||||
|
import type { FinalFormFieldProps } from '../fieldTypes';
|
||||||
|
|
||||||
import './SelectField.css';
|
import './SelectField.css';
|
||||||
|
|
||||||
const SelectField = ({ input, label, options, value }) => {
|
export interface SelectFieldOption<V extends string | number = string | number> {
|
||||||
const id = label + '-select-field';
|
value: V;
|
||||||
const labelId = id + '-label';
|
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 (
|
return (
|
||||||
<FormControl variant="outlined" margin="dense" className="select-field">
|
<FormControl variant="outlined" margin="dense" className="select-field">
|
||||||
|
|
@ -16,13 +31,15 @@ const SelectField = ({ input, label, options, value }) => {
|
||||||
<Select
|
<Select
|
||||||
labelId={labelId}
|
labelId={labelId}
|
||||||
id={id}
|
id={id}
|
||||||
value={value}
|
label={label}
|
||||||
{ ...input }
|
{...input}
|
||||||
>{
|
>
|
||||||
options.map((option, index) => (
|
{options.map(option => (
|
||||||
<MenuItem value={index} key={index}> { option } </MenuItem>
|
<MenuItem value={option.value} key={option.value}>
|
||||||
))
|
{option.label}
|
||||||
}</Select>
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,34 @@
|
||||||
import * as React from 'react'
|
import { ReactNode, SyntheticEvent } from 'react';
|
||||||
import { createPortal } from 'react-dom'
|
|
||||||
|
|
||||||
import Alert from '@mui/material/Alert';
|
import Alert, { AlertColor } from '@mui/material/Alert';
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
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';
|
import Snackbar from '@mui/material/Snackbar';
|
||||||
|
|
||||||
const iconMapping = {
|
const iconMapping = {
|
||||||
success: <CheckCircleIcon />
|
success: <CheckCircleIcon />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ToastProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: (event?: SyntheticEvent) => void;
|
||||||
|
severity?: AlertColor;
|
||||||
|
autoHideDuration?: number;
|
||||||
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toast(props) {
|
// MUI's Snackbar already self-portals to the end of document.body; adding our
|
||||||
const { open, onClose, severity = 'success', autoHideDuration = 10000, children } = props
|
// own createPortal wrapper would leak <div>s under React StrictMode's double-
|
||||||
|
// invoked effects. Render the Snackbar directly.
|
||||||
const rootElemRef = React.useRef(document.createElement('div'));
|
function Toast({ open, onClose, severity = 'success', autoHideDuration = 10000, children }: ToastProps) {
|
||||||
|
const handleClose = (event?: SyntheticEvent | Event, reason?: string) => {
|
||||||
React.useEffect(() => {
|
|
||||||
document.body.appendChild(rootElemRef.current)
|
|
||||||
return () => {
|
|
||||||
rootElemRef.current.remove();
|
|
||||||
}
|
|
||||||
}, [rootElemRef])
|
|
||||||
|
|
||||||
const handleClose = (event?: React.SyntheticEvent, reason?: string) => {
|
|
||||||
if (reason === 'clickaway') {
|
if (reason === 'clickaway') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onClose(event);
|
onClose(event as SyntheticEvent | undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const node = (
|
return (
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={open}
|
open={open}
|
||||||
autoHideDuration={autoHideDuration}
|
autoHideDuration={autoHideDuration}
|
||||||
|
|
@ -37,23 +36,18 @@ function Toast(props) {
|
||||||
slots={{ transition: TransitionLeft }}
|
slots={{ transition: TransitionLeft }}
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
>
|
>
|
||||||
<Alert onClose={handleClose} severity={severity} iconMapping={iconMapping}
|
<Alert
|
||||||
|
onClose={handleClose}
|
||||||
|
severity={severity}
|
||||||
|
iconMapping={iconMapping}
|
||||||
slotProps={{ message: { children } }}
|
slotProps={{ message: { children } }}
|
||||||
/>
|
/>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
)
|
|
||||||
if (!rootElemRef.current) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
node,
|
|
||||||
rootElemRef.current
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TransitionLeft(props) {
|
function TransitionLeft(props: SlideProps) {
|
||||||
return <Slide {...props} direction="left" />;
|
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 { ACTIONS, initialState, reducer, ToastEntry } from './reducer';
|
||||||
import Toast from './Toast'
|
import Toast from './Toast';
|
||||||
|
|
||||||
interface ToastEntry {
|
interface ToastContextValue {
|
||||||
isOpen: boolean,
|
toasts: Record<string, ToastEntry>;
|
||||||
children: ReactChild,
|
addToast: (key: string, children: ReactNode) => void;
|
||||||
|
openToast: (key: string) => void;
|
||||||
|
closeToast: (key: string) => void;
|
||||||
|
removeToast: (key: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToastState {
|
const ToastContext = createContext<ToastContextValue>({
|
||||||
toasts: Record<string, ToastEntry>,
|
|
||||||
addToast: (key, children) => void,
|
|
||||||
openToast: (key) => void,
|
|
||||||
closeToast: (key) => void,
|
|
||||||
removeToast: (key) => void,
|
|
||||||
}
|
|
||||||
|
|
||||||
const ToastContext: Context<any> = createContext<ToastState>({
|
|
||||||
toasts: {},
|
toasts: {},
|
||||||
addToast: (_key, _children) => {},
|
addToast: () => {},
|
||||||
openToast: (_key) => {},
|
openToast: () => {},
|
||||||
closeToast: (_key) => {},
|
closeToast: () => {},
|
||||||
removeToast: (_key) => {},
|
removeToast: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ToastProvider: FC<PropsWithChildren> = (props) => {
|
export const ToastProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||||
const { children } = props
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const [state, dispatch] = useReducer(reducer, initialState)
|
const providerState: ToastContextValue = {
|
||||||
const providerState = {
|
|
||||||
toasts: state.toasts,
|
toasts: state.toasts,
|
||||||
addToast: (key, children) => dispatch({ type: ACTIONS.ADD_TOAST, payload: { key, children } }),
|
addToast: (key, toastChildren) => dispatch({ type: ACTIONS.ADD_TOAST, payload: { key, children: toastChildren } }),
|
||||||
openToast: key => dispatch({ type: ACTIONS.OPEN_TOAST, payload: { key } }),
|
openToast: (key) => dispatch({ type: ACTIONS.OPEN_TOAST, payload: { key } }),
|
||||||
closeToast: key => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } }),
|
closeToast: (key) => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } }),
|
||||||
removeToast: key => dispatch({ type: ACTIONS.REMOVE_TOAST, payload: { key } }),
|
removeToast: (key) => dispatch({ type: ACTIONS.REMOVE_TOAST, payload: { key } }),
|
||||||
}
|
};
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={providerState}>
|
<ToastContext.Provider value={providerState}>
|
||||||
{children}
|
{children}
|
||||||
<div>
|
<div>
|
||||||
{Object.entries(state.toasts).map(([key, value]: [string, ToastEntry]) => {
|
{Object.entries(state.toasts).map(([key, entry]) => (
|
||||||
const { isOpen, children } = value;
|
<Toast
|
||||||
return (
|
key={key}
|
||||||
<Toast key={key} open={isOpen} onClose={() => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } })}>
|
open={entry.isOpen}
|
||||||
{children}
|
onClose={() => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } })}
|
||||||
</Toast>
|
>
|
||||||
)
|
{entry.children}
|
||||||
})}
|
</Toast>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ToastContext.Provider>
|
</ToastContext.Provider>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface ToastHookOptions {
|
export interface ToastHookOptions {
|
||||||
key: string,
|
key: string;
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useToast({ key, children }) {
|
export interface ToastHandle {
|
||||||
const { addToast, openToast, closeToast, removeToast } = useContext(ToastContext)
|
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(() => {
|
useEffect(() => {
|
||||||
addToast(key, children)
|
addToast(key, children);
|
||||||
}, [])
|
return () => {
|
||||||
|
removeToast(key);
|
||||||
|
};
|
||||||
|
}, [key]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openToast: () => openToast(key),
|
openToast: () => openToast(key),
|
||||||
closeToast: () => closeToast(key),
|
closeToast: () => closeToast(key),
|
||||||
removeToast: () => removeToast(key),
|
removeToast: () => removeToast(key),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,88 @@
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
export const ACTIONS = {
|
export const ACTIONS = {
|
||||||
ADD_TOAST: 'ADD_TOAST',
|
ADD_TOAST: 'ADD_TOAST',
|
||||||
OPEN_TOAST: 'OPEN_TOAST',
|
OPEN_TOAST: 'OPEN_TOAST',
|
||||||
CLOSE_TOAST: 'CLOSE_TOAST',
|
CLOSE_TOAST: 'CLOSE_TOAST',
|
||||||
REMOVE_TOAST: 'REMOVE_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 = {
|
export interface ToastState {
|
||||||
toasts: {}
|
toasts: Record<string, ToastEntry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reducer(state, { type, payload }) {
|
export const initialState: ToastState = {
|
||||||
const { key, children } = payload;
|
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: {
|
case ACTIONS.ADD_TOAST: {
|
||||||
|
const { key, children } = action.payload;
|
||||||
|
const existing = state.toasts[key];
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: {
|
toasts: {
|
||||||
...state.toasts,
|
...state.toasts,
|
||||||
[key]: {
|
[key]: existing
|
||||||
isOpen: false,
|
? { ...existing, refs: existing.refs + 1 }
|
||||||
children,
|
: { isOpen: false, children, refs: 1 },
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case ACTIONS.OPEN_TOAST: {
|
case ACTIONS.OPEN_TOAST: {
|
||||||
|
const { key } = action.payload;
|
||||||
|
const existing = state.toasts[key];
|
||||||
|
if (!existing) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: {
|
toasts: { ...state.toasts, [key]: { ...existing, isOpen: true } },
|
||||||
...state.toasts,
|
|
||||||
[key]: {
|
|
||||||
...state.toasts[key],
|
|
||||||
isOpen: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case ACTIONS.CLOSE_TOAST: {
|
case ACTIONS.CLOSE_TOAST: {
|
||||||
|
const { key } = action.payload;
|
||||||
|
const existing = state.toasts[key];
|
||||||
|
if (!existing) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: {
|
toasts: { ...state.toasts, [key]: { ...existing, isOpen: false } },
|
||||||
...state.toasts,
|
|
||||||
[key]: {
|
|
||||||
...state.toasts[key],
|
|
||||||
isOpen: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case ACTIONS.REMOVE_TOAST: {
|
case ACTIONS.REMOVE_TOAST: {
|
||||||
const newState = { ...state };
|
const { key } = action.payload;
|
||||||
delete newState.toasts[key];
|
const existing = state.toasts[key];
|
||||||
|
if (!existing) {
|
||||||
return newState;
|
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:
|
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 { TokenDTO } from '@app/services';
|
||||||
|
|
||||||
import './Token.css';
|
import './Token.css';
|
||||||
|
|
@ -10,10 +7,11 @@ interface TokenProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Token = ({ token }: TokenProps) => {
|
const Token = ({ token }: TokenProps) => {
|
||||||
const set = Array.isArray(token?.set) ? token?.set[0] : token?.set;
|
if (!token) {
|
||||||
return token && (
|
return null;
|
||||||
<img className="token" src={set?.picURL} alt={token?.name?.value} />
|
}
|
||||||
);
|
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;
|
export default Token;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
// eslint-disable-next-line
|
|
||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { TokenDTO } from '@app/services';
|
import { TokenDTO } from '@app/services';
|
||||||
|
|
||||||
import Token from '../Token/Token';
|
import Token from '../Token/Token';
|
||||||
|
|
@ -21,7 +18,7 @@ const TokenDetails = ({ token }: TokenProps) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
token && (
|
token && props && (
|
||||||
<div>
|
<div>
|
||||||
<div className='tokenDetails-attributes'>
|
<div className='tokenDetails-attributes'>
|
||||||
<div className='tokenDetails-attribute'>
|
<div className='tokenDetails-attribute'>
|
||||||
|
|
@ -29,52 +26,42 @@ const TokenDetails = ({ token }: TokenProps) => {
|
||||||
<span className='tokenDetails-attribute__value'>{token.name?.value}</span>
|
<span className='tokenDetails-attribute__value'>{token.name?.value}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{props.pt?.value && (
|
||||||
(!props.pt?.value) ? null : (
|
<div className='tokenDetails-attribute'>
|
||||||
<div className='tokenDetails-attribute'>
|
<span className='tokenDetails-attribute__label'>P/T:</span>
|
||||||
<span className='tokenDetails-attribute__label'>P/T:</span>
|
<span className='tokenDetails-attribute__value'>{props.pt.value}</span>
|
||||||
<span className='tokenDetails-attribute__value'>{props.pt.value}</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{props.colors?.value && (
|
||||||
!props.colors?.value ? null : (
|
<div className='tokenDetails-attribute'>
|
||||||
<div className='cardDetails-attribute'>
|
<span className='tokenDetails-attribute__label'>Color(s):</span>
|
||||||
<span className='cardDetails-attribute__label'>Color(s):</span>
|
<span className='tokenDetails-attribute__value'>{props.colors.value}</span>
|
||||||
<span className='cardDetails-attribute__value'>{props.colors.value}</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{props.maintype?.value && (
|
||||||
!props.maintype?.value ? null : (
|
<div className='tokenDetails-attribute'>
|
||||||
<div className='cardDetails-attribute'>
|
<span className='tokenDetails-attribute__label'>Main Type:</span>
|
||||||
<span className='cardDetails-attribute__label'>Main Type:</span>
|
<span className='tokenDetails-attribute__value'>{props.maintype.value}</span>
|
||||||
<span className='cardDetails-attribute__value'>{props.maintype.value}</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{props.type?.value && (
|
||||||
!props.type?.value ? null : (
|
<div className='tokenDetails-attribute'>
|
||||||
<div className='cardDetails-attribute'>
|
<span className='tokenDetails-attribute__label'>Type:</span>
|
||||||
<span className='cardDetails-attribute__label'>Type:</span>
|
<span className='tokenDetails-attribute__value'>{props.type.value}</span>
|
||||||
<span className='cardDetails-attribute__value'>{props.type.value}</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{token.text?.value && (
|
||||||
!token.text?.value ? null : (
|
<div className='tokenDetails-text'>
|
||||||
<div className='tokenDetails-text'>
|
<div className='tokenDetails-text__current'>
|
||||||
<div className='tokenDetails-text__current'>
|
{token.text.value}
|
||||||
{token.text.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ const UserDisplay = ({ user }: UserDisplayProps) => {
|
||||||
<div className="user-display">
|
<div className="user-display">
|
||||||
<NavLink to={generatePath(App.RouteEnum.PLAYER, { name })} className="plain-link">
|
<NavLink to={generatePath(App.RouteEnum.PLAYER, { name })} className="plain-link">
|
||||||
<div className="user-display__details" onContextMenu={handleClick}>
|
<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 className="user-display__name single-line-ellipsis">{name}</div>
|
||||||
</div>
|
</div>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
// eslint-disable-next-line
|
import { ReactNode } from 'react';
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import { List, RowComponentProps } from 'react-window';
|
import { List, RowComponentProps } from 'react-window';
|
||||||
|
|
||||||
import './VirtualList.css';
|
import './VirtualList.css';
|
||||||
|
|
||||||
interface RowData {
|
interface RowData {
|
||||||
items: any[];
|
items: ReactNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VirtualListProps {
|
||||||
|
items: ReactNode[];
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Row = ({ index, style, items }: RowComponentProps<RowData>) => (
|
const Row = ({ index, style, items }: RowComponentProps<RowData>) => (
|
||||||
|
|
@ -15,7 +19,7 @@ const Row = ({ index, style, items }: RowComponentProps<RowData>) => (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const VirtualList = ({ items, className = '', size = 30 }) => (
|
const VirtualList = ({ items, className = '', size = 30 }: VirtualListProps) => (
|
||||||
<div className="virtual-list">
|
<div className="virtual-list">
|
||||||
<List<RowData>
|
<List<RowData>
|
||||||
className={`virtual-list__list ${className}`}
|
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
|
// Common components
|
||||||
export { default as Card } from './Card/Card';
|
export { default as Card } from './Card/Card';
|
||||||
export { default as CardDetails } from './CardDetails/CardDetails';
|
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 { UserDisplay, VirtualList, AuthGuard, LanguageDropdown } from '@app/components';
|
||||||
import Layout from '../Layout/Layout';
|
import Layout from '../Layout/Layout';
|
||||||
|
|
||||||
import AddToBuddies from './AddToBuddies';
|
import AddUserForm from './AddUserForm';
|
||||||
import AddToIgnore from './AddToIgnore';
|
|
||||||
import { useAccount } from './useAccount';
|
import { useAccount } from './useAccount';
|
||||||
|
|
||||||
import './Account.css';
|
import './Account.css';
|
||||||
|
|
@ -44,7 +43,7 @@ const Account = () => {
|
||||||
))}
|
))}
|
||||||
/>
|
/>
|
||||||
<div style={{ borderTop: '1px solid' }}>
|
<div style={{ borderTop: '1px solid' }}>
|
||||||
<AddToBuddies onSubmit={handleAddToBuddies} />
|
<AddUserForm label="Add to Buddies" onSubmit={handleAddToBuddies} />
|
||||||
</div>
|
</div>
|
||||||
</Paper>
|
</Paper>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -61,7 +60,7 @@ const Account = () => {
|
||||||
))}
|
))}
|
||||||
/>
|
/>
|
||||||
<div style={{ borderTop: '1px solid' }}>
|
<div style={{ borderTop: '1px solid' }}>
|
||||||
<AddToIgnore onSubmit={handleAddToIgnore} />
|
<AddUserForm label="Add to Ignore" onSubmit={handleAddToIgnore} />
|
||||||
</div>
|
</div>
|
||||||
</Paper>
|
</Paper>
|
||||||
</div>
|
</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 { useWebClient } from '@app/hooks';
|
||||||
import { ServerSelectors, useAppSelector } from '@app/store';
|
import { ServerSelectors, useAppSelector } from '@app/store';
|
||||||
|
import { Data } from '@app/types';
|
||||||
|
|
||||||
export interface Account {
|
export interface Account {
|
||||||
buddyList: any[];
|
buddyList: Data.ServerInfo_User[];
|
||||||
ignoreList: any[];
|
ignoreList: Data.ServerInfo_User[];
|
||||||
serverName: string | undefined;
|
serverName: string | undefined;
|
||||||
serverVersion: string | undefined;
|
serverVersion: string | undefined;
|
||||||
user: any;
|
user: Data.ServerInfo_User | null;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
handleAddToBuddies: (args: { userName: string }) => void;
|
handleAddToBuddies: (args: { userName: string }) => void;
|
||||||
handleAddToIgnore: (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 serverVersion = useAppSelector((state) => ServerSelectors.getVersion(state));
|
||||||
const user = useAppSelector((state) => ServerSelectors.getUser(state));
|
const user = useAppSelector((state) => ServerSelectors.getUser(state));
|
||||||
const webClient = useWebClient();
|
const webClient = useWebClient();
|
||||||
const { avatarBmp } = user || {};
|
const avatarBmp = user?.avatarBmp;
|
||||||
|
|
||||||
const avatarUrl = useMemo(() => {
|
const avatarUrl = useMemo(() => {
|
||||||
if (!avatarBmp) {
|
if (!avatarBmp) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return URL.createObjectURL(new Blob([avatarBmp as BlobPart], { type: 'image/png' }));
|
return URL.createObjectURL(new Blob([avatarBmp], { type: 'image/png' }));
|
||||||
}, [avatarBmp]);
|
}, [avatarBmp]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -8,23 +8,22 @@ import FeatureDetection from './FeatureDetection';
|
||||||
|
|
||||||
import './AppShell.css';
|
import './AppShell.css';
|
||||||
|
|
||||||
import { ToastProvider } from '@app/components'
|
import { ToastProvider } from '@app/components';
|
||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.onbeforeunload = () => true;
|
window.onbeforeunload = () => true;
|
||||||
|
return () => {
|
||||||
|
window.onbeforeunload = null;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleContextMenu = (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback="loading">
|
<Suspense fallback="loading">
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<div className="AppShell" onContextMenu={handleContextMenu}>
|
<div className="AppShell">
|
||||||
<Router>
|
<Router>
|
||||||
<FeatureDetection />
|
<FeatureDetection />
|
||||||
<Routes />
|
<Routes />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import React from 'react';
|
|
||||||
import { Route, Routes } from 'react-router-dom';
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
import { App } from '@app/types';
|
import { App } from '@app/types';
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ function Decks() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<AuthGuard />
|
<AuthGuard />
|
||||||
<span>"Decks"</span>
|
<span>Decks</span>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
import LeftNav from './LeftNav';
|
import LeftNav from './LeftNav';
|
||||||
|
|
||||||
import './Layout.css'
|
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 { children, className, showNav = true, noHeightLimit = false } = props;
|
||||||
const containerClasses = ['layout']
|
const containerClasses = ['layout'];
|
||||||
if (noHeightLimit === true) {
|
if (noHeightLimit) {
|
||||||
containerClasses.push('layout--no-height-limit')
|
containerClasses.push('layout--no-height-limit');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -29,11 +38,4 @@ function BottomBar() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayoutProps {
|
|
||||||
showNav?: boolean;
|
|
||||||
children: any;
|
|
||||||
className?: string;
|
|
||||||
noHeightLimit?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Layout;
|
export default Layout;
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,6 @@
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.LeftNav-nav {
|
|
||||||
}
|
|
||||||
|
|
||||||
.LeftNav-nav__links {
|
.LeftNav-nav__links {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column;
|
flex-flow: column;
|
||||||
|
|
@ -102,27 +99,7 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.LeftNav-nav__action {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.LeftNav-nav__action button {
|
.LeftNav-nav__action button {
|
||||||
color: white;
|
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) => (
|
{state.options.map((option) => (
|
||||||
<MenuItem key={option} onClick={() => handleMenuItemClick(option)}>
|
<MenuItem key={option.label} onClick={() => handleMenuItemClick(option)}>
|
||||||
{option}
|
{option.label}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,69 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate, generatePath } from 'react-router-dom';
|
import { useNavigate, generatePath } from 'react-router-dom';
|
||||||
|
|
||||||
import { useWebClient } from '@app/hooks';
|
import { useWebClient } from '@app/hooks';
|
||||||
import { RoomsSelectors, ServerSelectors, useAppSelector } from '@app/store';
|
import { RoomsSelectors, ServerSelectors, useAppSelector } from '@app/store';
|
||||||
import { App } from '@app/types';
|
import { App } from '@app/types';
|
||||||
|
|
||||||
|
export interface LeftNavOption {
|
||||||
|
label: string;
|
||||||
|
route: App.RouteEnum;
|
||||||
|
}
|
||||||
|
|
||||||
interface LeftNavState {
|
interface LeftNavState {
|
||||||
anchorEl: Element | null;
|
anchorEl: Element | null;
|
||||||
showCardImportDialog: boolean;
|
showCardImportDialog: boolean;
|
||||||
options: string[];
|
options: LeftNavOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeftNav {
|
export interface LeftNav {
|
||||||
joinedRooms: any[];
|
joinedRooms: ReturnType<typeof RoomsSelectors.getJoinedRooms>;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
state: LeftNavState;
|
state: LeftNavState;
|
||||||
handleMenuOpen: (event: React.MouseEvent) => void;
|
handleMenuOpen: (event: React.MouseEvent) => void;
|
||||||
handleMenuItemClick: (option: string) => void;
|
handleMenuItemClick: (option: LeftNavOption) => void;
|
||||||
handleMenuClose: () => void;
|
handleMenuClose: () => void;
|
||||||
leaveRoom: (event: React.MouseEvent, roomId: number) => void;
|
leaveRoom: (event: React.MouseEvent, roomId: number) => void;
|
||||||
openImportCardWizard: () => void;
|
openImportCardWizard: () => void;
|
||||||
closeImportCardWizard: () => 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 {
|
export function useLeftNav(): LeftNav {
|
||||||
const joinedRooms = useAppSelector((state) => RoomsSelectors.getJoinedRooms(state));
|
const joinedRooms = useAppSelector((state) => RoomsSelectors.getJoinedRooms(state));
|
||||||
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
|
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
|
||||||
const isModerator = useAppSelector(ServerSelectors.getIsUserModerator);
|
const isModerator = useAppSelector(ServerSelectors.getIsUserModerator);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const webClient = useWebClient();
|
const webClient = useWebClient();
|
||||||
const [state, setState] = useState<LeftNavState>({
|
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
|
||||||
anchorEl: null,
|
const [showCardImportDialog, setShowCardImportDialog] = useState(false);
|
||||||
showCardImportDialog: false,
|
|
||||||
options: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
const options = useMemo<LeftNavOption[]>(
|
||||||
let options: string[] = [
|
() => (isModerator ? [...BASE_OPTIONS, ...MODERATOR_OPTIONS] : BASE_OPTIONS),
|
||||||
'Account',
|
[isModerator],
|
||||||
'Replays',
|
);
|
||||||
];
|
|
||||||
|
|
||||||
if (isModerator) {
|
const state: LeftNavState = { anchorEl, showCardImportDialog, options };
|
||||||
options = [
|
|
||||||
...options,
|
|
||||||
'Administration',
|
|
||||||
'Logs',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
setState((s) => ({ ...s, options }));
|
|
||||||
}, [isModerator]);
|
|
||||||
|
|
||||||
const handleMenuOpen = (event: React.MouseEvent) => {
|
const handleMenuOpen = (event: React.MouseEvent) => {
|
||||||
setState((s) => ({ ...s, anchorEl: event.target as Element }));
|
setAnchorEl(event.target as Element);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMenuItemClick = (option: string) => {
|
const handleMenuItemClick = (option: LeftNavOption) => {
|
||||||
const route = App.RouteEnum[option.toUpperCase()];
|
navigate(generatePath(option.route));
|
||||||
navigate(generatePath(route));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMenuClose = () => {
|
const handleMenuClose = () => {
|
||||||
setState((s) => ({ ...s, anchorEl: null }));
|
setAnchorEl(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const leaveRoom = (event: React.MouseEvent, roomId: number) => {
|
const leaveRoom = (event: React.MouseEvent, roomId: number) => {
|
||||||
|
|
@ -71,12 +72,12 @@ export function useLeftNav(): LeftNav {
|
||||||
};
|
};
|
||||||
|
|
||||||
const openImportCardWizard = () => {
|
const openImportCardWizard = () => {
|
||||||
setState((s) => ({ ...s, showCardImportDialog: true }));
|
setShowCardImportDialog(true);
|
||||||
handleMenuClose();
|
handleMenuClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeImportCardWizard = () => {
|
const closeImportCardWizard = () => {
|
||||||
setState((s) => ({ ...s, showCardImportDialog: false }));
|
setShowCardImportDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -115,8 +115,8 @@
|
||||||
margin: 40px 0 20px;
|
margin: 40px 0 20px;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-content__description-subtitle2 {
|
.login-content__description-subtitle2 {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@ import { useCallback, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useToast } from '@app/components';
|
import { useToast } from '@app/components';
|
||||||
|
import type {
|
||||||
|
LoginFormValues,
|
||||||
|
RegisterFormValues,
|
||||||
|
RequestPasswordResetFormValues,
|
||||||
|
ResetPasswordFormValues,
|
||||||
|
} from '@app/forms';
|
||||||
import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
||||||
import { getHostPort } from '@app/services';
|
import { getHostPort } from '@app/services';
|
||||||
import { ServerSelectors, ServerTypes, useAppSelector } from '@app/store';
|
import { ServerSelectors, ServerTypes, useAppSelector } from '@app/store';
|
||||||
|
|
@ -17,16 +23,15 @@ export interface LoginDialogState {
|
||||||
export interface Login {
|
export interface Login {
|
||||||
description: string | undefined;
|
description: string | undefined;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
pendingActivationOptions: WebsocketTypes.PendingActivationContext | null;
|
|
||||||
dialogState: LoginDialogState;
|
dialogState: LoginDialogState;
|
||||||
userToResetPassword: string | null;
|
userToResetPassword: string | null;
|
||||||
submitButtonDisabled: boolean;
|
submitButtonDisabled: boolean;
|
||||||
handleLogin: (form: any) => void;
|
handleLogin: (form: LoginFormValues) => void;
|
||||||
showDescription: () => boolean;
|
showDescription: () => boolean;
|
||||||
handleRegistrationDialogSubmit: (form: any) => void;
|
handleRegistrationDialogSubmit: (form: RegisterFormValues) => void;
|
||||||
handleAccountActivationDialogSubmit: (args: { token: string }) => void;
|
handleAccountActivationDialogSubmit: (args: { token: string }) => void;
|
||||||
handleRequestPasswordResetDialogSubmit: (form: any) => void;
|
handleRequestPasswordResetDialogSubmit: (form: RequestPasswordResetFormValues) => void;
|
||||||
handleResetPasswordDialogSubmit: (args: any) => void;
|
handleResetPasswordDialogSubmit: (form: ResetPasswordFormValues) => void;
|
||||||
skipTokenRequest: (userName: string) => void;
|
skipTokenRequest: (userName: string) => void;
|
||||||
closeRequestPasswordResetDialog: () => void;
|
closeRequestPasswordResetDialog: () => void;
|
||||||
openRequestPasswordResetDialog: () => void;
|
openRequestPasswordResetDialog: () => void;
|
||||||
|
|
@ -46,7 +51,7 @@ export function useLogin(): Login {
|
||||||
const [pendingActivationOptions, setPendingActivationOptions] =
|
const [pendingActivationOptions, setPendingActivationOptions] =
|
||||||
useState<WebsocketTypes.PendingActivationContext | null>(null);
|
useState<WebsocketTypes.PendingActivationContext | null>(null);
|
||||||
|
|
||||||
const rememberLoginRef = useRef<any>(null);
|
const rememberLoginRef = useRef<LoginFormValues | RegisterFormValues | null>(null);
|
||||||
const knownHosts = useKnownHosts();
|
const knownHosts = useKnownHosts();
|
||||||
const [dialogState, setDialogState] = useState<LoginDialogState>({
|
const [dialogState, setDialogState] = useState<LoginDialogState>({
|
||||||
passwordResetRequestDialog: false,
|
passwordResetRequestDialog: false,
|
||||||
|
|
@ -113,13 +118,13 @@ export function useLogin(): Login {
|
||||||
setPendingActivationOptions(null);
|
setPendingActivationOptions(null);
|
||||||
}, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []);
|
}, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []);
|
||||||
|
|
||||||
useReduxEffect(({ payload: { options } }) => {
|
useReduxEffect<{ options: WebsocketTypes.PendingActivationContext }>(({ payload: { options } }) => {
|
||||||
setPendingActivationOptions(options);
|
setPendingActivationOptions(options);
|
||||||
closeRegistrationDialog();
|
closeRegistrationDialog();
|
||||||
openActivateAccountDialog();
|
openActivateAccountDialog();
|
||||||
}, ServerTypes.ACCOUNT_AWAITING_ACTIVATION, []);
|
}, ServerTypes.ACCOUNT_AWAITING_ACTIVATION, []);
|
||||||
|
|
||||||
const onSubmitLogin = useCallback((loginForm) => {
|
const onSubmitLogin = useCallback((loginForm: LoginFormValues) => {
|
||||||
rememberLoginRef.current = loginForm;
|
rememberLoginRef.current = loginForm;
|
||||||
const { userName, password, selectedHost, remember } = loginForm;
|
const { userName, password, selectedHost, remember } = loginForm;
|
||||||
|
|
||||||
|
|
@ -134,7 +139,7 @@ export function useLogin(): Login {
|
||||||
}
|
}
|
||||||
|
|
||||||
webClient.request.authentication.login(options);
|
webClient.request.authentication.login(options);
|
||||||
}, []);
|
}, [webClient]);
|
||||||
|
|
||||||
const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin);
|
const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin);
|
||||||
|
|
||||||
|
|
@ -142,7 +147,13 @@ export function useLogin(): Login {
|
||||||
resetSubmitButton();
|
resetSubmitButton();
|
||||||
}, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []);
|
}, [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, {
|
knownHosts.update(selectedHost.id, {
|
||||||
remember,
|
remember,
|
||||||
userName: remember ? userName : null,
|
userName: remember ? userName : null,
|
||||||
|
|
@ -150,9 +161,10 @@ export function useLogin(): Login {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useReduxEffect(({ payload: { options: { hashedPassword } } }) => {
|
useReduxEffect<{ options: WebsocketTypes.LoginSuccessContext }>(({ payload: { options } }) => {
|
||||||
if (rememberLoginRef.current) {
|
const loginForm = rememberLoginRef.current;
|
||||||
updateHost(hashedPassword, rememberLoginRef.current);
|
if (loginForm && 'remember' in loginForm) {
|
||||||
|
updateHost(options.hashedPassword, loginForm);
|
||||||
}
|
}
|
||||||
}, ServerTypes.LOGIN_SUCCESSFUL, []);
|
}, ServerTypes.LOGIN_SUCCESSFUL, []);
|
||||||
|
|
||||||
|
|
@ -162,7 +174,7 @@ export function useLogin(): Login {
|
||||||
return Boolean(!isConnected && description?.length);
|
return Boolean(!isConnected && description?.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRegistrationDialogSubmit = (registerForm: any) => {
|
const handleRegistrationDialogSubmit = (registerForm: RegisterFormValues) => {
|
||||||
rememberLoginRef.current = registerForm;
|
rememberLoginRef.current = registerForm;
|
||||||
const { userName, password, email, country, realName, selectedHost } = 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 { userName, email, selectedHost } = form;
|
||||||
const { host, port } = getHostPort(selectedHost);
|
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);
|
const { host, port } = getHostPort(selectedHost);
|
||||||
webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port });
|
webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port });
|
||||||
};
|
};
|
||||||
|
|
@ -218,7 +235,6 @@ export function useLogin(): Login {
|
||||||
return {
|
return {
|
||||||
description,
|
description,
|
||||||
isConnected,
|
isConnected,
|
||||||
pendingActivationOptions,
|
|
||||||
dialogState,
|
dialogState,
|
||||||
userToResetPassword,
|
userToResetPassword,
|
||||||
submitButtonDisabled,
|
submitButtonDisabled,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import AppBar from '@mui/material/AppBar';
|
import AppBar from '@mui/material/AppBar';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
|
|
@ -8,37 +11,99 @@ import TableHead from '@mui/material/TableHead';
|
||||||
import TableRow from '@mui/material/TableRow';
|
import TableRow from '@mui/material/TableRow';
|
||||||
import Tab from '@mui/material/Tab';
|
import Tab from '@mui/material/Tab';
|
||||||
import Tabs from '@mui/material/Tabs';
|
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 { useLogResults } from './useLogResults';
|
||||||
|
|
||||||
import './LogResults.css';
|
import './LogResults.css';
|
||||||
|
|
||||||
const LogResults = (props) => {
|
interface LogResultsProps {
|
||||||
const { logs } = props;
|
logs: ServerStateLogs;
|
||||||
|
}
|
||||||
|
|
||||||
const hasRoomLogs = logs.room && logs.room.length;
|
interface HeaderCell {
|
||||||
const hasGameLogs = logs.game && logs.game.length;
|
label: string;
|
||||||
const hasChatLogs = logs.chat && logs.chat.length;
|
}
|
||||||
|
|
||||||
|
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 { value, handleChange } = useLogResults();
|
||||||
|
|
||||||
const headerCells = [
|
const headerCells: HeaderCell[] = [
|
||||||
{ label: 'Time' },
|
{ label: t('Logs.column.time') },
|
||||||
{ label: 'Sender Name' },
|
{ label: t('Logs.column.senderName') },
|
||||||
{ label: 'Sender IP' },
|
{ label: t('Logs.column.senderIp') },
|
||||||
{ label: 'Message' },
|
{ label: t('Logs.column.message') },
|
||||||
{ label: 'Target ID' },
|
{ label: t('Logs.column.targetId') },
|
||||||
{ label: 'Target Name' },
|
{ label: t('Logs.column.targetName') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const roomCount = logs.room?.length ?? 0;
|
||||||
|
const gameCount = logs.game?.length ?? 0;
|
||||||
|
const chatCount = logs.chat?.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AppBar position="static">
|
<AppBar position="static">
|
||||||
<Tabs value={value} onChange={handleChange} aria-label="simple tabs example">
|
<Tabs value={value} onChange={handleChange} aria-label={t('Logs.title', { defaultValue: 'Log Results' })}>
|
||||||
<Tab label={'Rooms' + (hasRoomLogs ? ` [${logs.room.length}]` : '')} {...a11yProps(0)} />
|
<Tab label={`${t('Logs.tab.rooms')}${roomCount > 0 ? ` [${roomCount}]` : ''}`} {...a11yProps(0)} />
|
||||||
<Tab label={'Games' + (hasGameLogs ? ` [${logs.game.length}]` : '')} {...a11yProps(1)} />
|
<Tab label={`${t('Logs.tab.games')}${gameCount > 0 ? ` [${gameCount}]` : ''}`} {...a11yProps(1)} />
|
||||||
<Tab label={'Chats' + (hasChatLogs ? ` [${logs.chat.length}]` : '')} {...a11yProps(2)} />
|
<Tab label={`${t('Logs.tab.chats')}${chatCount > 0 ? ` [${chatCount}]` : ''}`} {...a11yProps(2)} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
<TabPanel value={value} index={0}>
|
<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;
|
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 { useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useToast } from '@app/components';
|
||||||
import { useWebClient } from '@app/hooks';
|
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';
|
import { Data } from '@app/types';
|
||||||
|
|
||||||
const MAXIMUM_RESULTS = 1000;
|
const MAXIMUM_RESULTS = 1000;
|
||||||
|
|
||||||
export interface Logs {
|
export interface Logs {
|
||||||
logs: any;
|
logs: ServerStateLogs;
|
||||||
onSubmit: (fields: Data.ViewLogHistoryParams) => void;
|
onSubmit: (fields: Data.ViewLogHistoryParams) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLogs(): Logs {
|
export function useLogs(): Logs {
|
||||||
|
const { t } = useTranslation();
|
||||||
const logs = useAppSelector((state) => ServerSelectors.getLogs(state));
|
const logs = useAppSelector((state) => ServerSelectors.getLogs(state));
|
||||||
const webClient = useWebClient();
|
const webClient = useWebClient();
|
||||||
|
const { openToast } = useToast({
|
||||||
|
key: 'logs-empty-filter',
|
||||||
|
children: t('Logs.message.emptyFilter'),
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -21,26 +28,29 @@ export function useLogs(): Logs {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const trimFields = (fields: any) => {
|
const trimFields = (fields: Data.ViewLogHistoryParams): Data.ViewLogHistoryParams => {
|
||||||
const result: any = {};
|
const result: Data.ViewLogHistoryParams = { ...fields };
|
||||||
for (const [key, field] of Object.entries(fields)) {
|
for (const key of Object.keys(result) as (keyof Data.ViewLogHistoryParams)[]) {
|
||||||
|
const field = result[key];
|
||||||
if (typeof field === 'string') {
|
if (typeof field === 'string') {
|
||||||
const trimmed = field.trim();
|
const trimmed = field.trim();
|
||||||
if (trimmed) {
|
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;
|
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 onSubmit = (fields: Data.ViewLogHistoryParams) => {
|
||||||
const trimmedFields: any = trimFields(fields);
|
const trimmedFields = trimFields(fields);
|
||||||
const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields;
|
const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields as
|
||||||
|
Data.ViewLogHistoryParams & { logLocation?: Record<string, unknown> };
|
||||||
|
|
||||||
const required = [userName, ipAddress, gameName, gameId, message].filter(Boolean);
|
const required = [userName, ipAddress, gameName, gameId, message].filter(Boolean);
|
||||||
|
|
||||||
|
|
@ -53,7 +63,7 @@ export function useLogs(): Logs {
|
||||||
if (required.length) {
|
if (required.length) {
|
||||||
webClient.request.moderator.viewLogHistory(trimmedFields);
|
webClient.request.moderator.viewLogHistory(trimmedFields);
|
||||||
} else {
|
} 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 Layout from '../Layout/Layout';
|
||||||
|
|
||||||
import { AuthGuard } from '@app/components';
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<AuthGuard />
|
<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>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Player;
|
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 { useNavigate } from 'react-router-dom';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
RoomsSelectors,
|
RoomsSelectors,
|
||||||
ServerSelectors,
|
ServerSelectors,
|
||||||
useAppSelector,
|
useAppSelector,
|
||||||
|
type GameFilters,
|
||||||
} from '@app/store';
|
} from '@app/store';
|
||||||
import { useReduxEffect, useWebClient } from '@app/hooks';
|
import { useReduxEffect, useWebClient } from '@app/hooks';
|
||||||
import { App, type Enriched } from '@app/types';
|
import { App, type Enriched } from '@app/types';
|
||||||
|
|
@ -115,7 +116,7 @@ const GameSelector = ({ room }: GameSelectorProps) => {
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFilterSubmit = (next) => {
|
const handleFilterSubmit = (next: GameFilters) => {
|
||||||
RoomsDispatch.setGameFilters(roomId, next);
|
RoomsDispatch.setGameFilters(roomId, next);
|
||||||
setFilterOpen(false);
|
setFilterOpen(false);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import React from 'react';
|
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||||
import FilterListOffIcon from '@mui/icons-material/FilterListOff';
|
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 { Message } from '@app/components';
|
||||||
|
import type { Enriched } from '@app/types';
|
||||||
|
|
||||||
import './Messages.css';
|
import './Messages.css';
|
||||||
|
|
||||||
const Messages = ({ messages }) => (
|
interface MessagesProps {
|
||||||
|
messages?: Enriched.Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const Messages = ({ messages }: MessagesProps) => (
|
||||||
<div className="messages">
|
<div className="messages">
|
||||||
{
|
{
|
||||||
messages && messages.map((message) => (
|
messages && messages.map((message, idx) => (
|
||||||
<div className="message-wrapper" key={message.timeReceived}>
|
<div className="message-wrapper" key={`${message.timeReceived}-${idx}`}>
|
||||||
<Message message={message} />
|
<Message message={message} />
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { useOpenGames } from './useOpenGames';
|
||||||
import './OpenGames.css';
|
import './OpenGames.css';
|
||||||
|
|
||||||
interface OpenGamesProps {
|
interface OpenGamesProps {
|
||||||
room: { info: { roomId: number } };
|
room: Enriched.Room;
|
||||||
onActivateGame?: (gameId: number) => void;
|
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';
|
import { InputAction } from '@app/components';
|
||||||
|
|
||||||
const SayMessage = ({ onSubmit }) => (
|
interface SayMessageProps {
|
||||||
|
onSubmit: (args: { message: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SayMessage = ({ onSubmit }: SayMessageProps) => (
|
||||||
<Form onSubmit={onSubmit}>
|
<Form onSubmit={onSubmit}>
|
||||||
{({ handleSubmit, form }) => (
|
{({ handleSubmit, form }) => (
|
||||||
<form onSubmit={e => {
|
<form onSubmit={e => {
|
||||||
handleSubmit(e)
|
handleSubmit(e);
|
||||||
form.restart()
|
form.restart();
|
||||||
}}>
|
}}>
|
||||||
<InputAction action="Send" label="Chat" name="message" />
|
<InputAction action="Send" label="Chat" name="message" />
|
||||||
</form>
|
</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 { useNavigate, useParams, generatePath } from 'react-router-dom';
|
||||||
|
|
||||||
import { useWebClient } from '@app/hooks';
|
import { useWebClient } from '@app/hooks';
|
||||||
import { RoomsSelectors, useAppSelector } from '@app/store';
|
import { RoomsSelectors, useAppSelector } from '@app/store';
|
||||||
import { App } from '@app/types';
|
import { App, Data, Enriched } from '@app/types';
|
||||||
|
|
||||||
export interface Room {
|
export interface Room {
|
||||||
roomId: number;
|
roomId: number;
|
||||||
room: any;
|
room: Enriched.Room | undefined;
|
||||||
roomMessages: any;
|
roomMessages: Enriched.Message[] | undefined;
|
||||||
users: any[];
|
users: Data.ServerInfo_User[];
|
||||||
handleRoomSay: (args: { message: string }) => void;
|
handleRoomSay: (args: { message: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,23 +20,24 @@ export function useRoom(): Room {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
const roomId = parseInt(params.roomId, 10);
|
const parsed = params.roomId != null ? parseInt(params.roomId, 10) : NaN;
|
||||||
const room = rooms[roomId];
|
const roomId = Number.isNaN(parsed) ? -1 : parsed;
|
||||||
const roomMessages = messages[roomId];
|
const room = roomId === -1 ? undefined : rooms[roomId];
|
||||||
|
const roomMessages = roomId === -1 ? undefined : messages[roomId];
|
||||||
const users = useAppSelector((state) => RoomsSelectors.getSortedRoomUsers(state, roomId));
|
const users = useAppSelector((state) => RoomsSelectors.getSortedRoomUsers(state, roomId));
|
||||||
const webClient = useWebClient();
|
const webClient = useWebClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!joined.find((r) => r.info.roomId === roomId)) {
|
if (roomId === -1 || !joined.find((r) => r.info.roomId === roomId)) {
|
||||||
navigate(generatePath(App.RouteEnum.SERVER));
|
navigate(generatePath(App.RouteEnum.SERVER));
|
||||||
}
|
}
|
||||||
}, [joined]);
|
}, [joined, roomId, navigate]);
|
||||||
|
|
||||||
const handleRoomSay = ({ message }: { message: string }) => {
|
const handleRoomSay = useCallback(({ message }: { message: string }) => {
|
||||||
if (message) {
|
if (message) {
|
||||||
webClient.request.rooms.roomSay(roomId, message);
|
webClient.request.rooms.roomSay(roomId, message);
|
||||||
}
|
}
|
||||||
};
|
}, [webClient, roomId]);
|
||||||
|
|
||||||
return { roomId, room, roomMessages, users, handleRoomSay };
|
return { roomId, room, roomMessages, users, handleRoomSay };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
.rooms {
|
|
||||||
}
|
|
||||||
|
|
||||||
.rooms-header,
|
.rooms-header,
|
||||||
.room {
|
.room {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
// eslint-disable-next-line
|
import { useMemo } from 'react';
|
||||||
import React from "react";
|
|
||||||
import { generatePath, useNavigate } from 'react-router-dom';
|
import { generatePath, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
|
|
@ -10,21 +9,31 @@ import TableHead from '@mui/material/TableHead';
|
||||||
import TableRow from '@mui/material/TableRow';
|
import TableRow from '@mui/material/TableRow';
|
||||||
|
|
||||||
import { useWebClient } from '@app/hooks';
|
import { useWebClient } from '@app/hooks';
|
||||||
import { App } from '@app/types';
|
import { App, Enriched } from '@app/types';
|
||||||
|
|
||||||
import './Rooms.css';
|
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 navigate = useNavigate();
|
||||||
const webClient = useWebClient();
|
const webClient = useWebClient();
|
||||||
|
|
||||||
function onClick(roomId) {
|
const joinedRoomIds = useMemo(
|
||||||
if (joinedRooms.find(room => room.info.roomId === roomId)) {
|
() => new Set(joinedRooms.map((room) => room.info.roomId)),
|
||||||
navigate(generatePath(App.RouteEnum.ROOM, { roomId }));
|
[joinedRooms],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClick = (roomId: number) => {
|
||||||
|
if (joinedRoomIds.has(roomId)) {
|
||||||
|
navigate(generatePath(App.RouteEnum.ROOM, { roomId: String(roomId) }));
|
||||||
} else {
|
} else {
|
||||||
webClient.request.rooms.joinRoom(roomId);
|
webClient.request.rooms.joinRoom(roomId);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rooms">
|
<div className="rooms">
|
||||||
|
|
@ -40,7 +49,7 @@ const Rooms = ({ rooms, joinedRooms }) => {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{ Object.values(rooms).map((room) => {
|
{Object.values(rooms).map((room) => {
|
||||||
const { description, gameCount, name, permissionlevel, playerCount, roomId } = room.info;
|
const { description, gameCount, name, permissionlevel, playerCount, roomId } = room.info;
|
||||||
return (
|
return (
|
||||||
<TableRow key={roomId}>
|
<TableRow key={roomId}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import { useMemo } from 'react';
|
||||||
import { generatePath, useNavigate } from 'react-router-dom';
|
import { generatePath, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import ListItemButton from '@mui/material/ListItemButton';
|
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 { AuthGuard, ThreePaneLayout, UserDisplay, VirtualList } from '@app/components';
|
||||||
import { useReduxEffect } from '@app/hooks';
|
import { useReduxEffect } from '@app/hooks';
|
||||||
import { RoomsSelectors, RoomsTypes, ServerSelectors } from '@app/store';
|
import { RoomsSelectors, RoomsTypes, ServerSelectors, useAppSelector } from '@app/store';
|
||||||
import { App } from '@app/types';
|
import { App, Data } from '@app/types';
|
||||||
import { useAppSelector } from '@app/store';
|
|
||||||
import Rooms from './Rooms';
|
import Rooms from './Rooms';
|
||||||
import Layout from '../Layout/Layout';
|
import Layout from '../Layout/Layout';
|
||||||
|
|
||||||
|
|
@ -21,11 +20,20 @@ const Server = () => {
|
||||||
const users = useAppSelector(state => ServerSelectors.getSortedUsers(state));
|
const users = useAppSelector(state => ServerSelectors.getSortedUsers(state));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useReduxEffect((action: any) => {
|
useReduxEffect<{ roomInfo: Data.ServerInfo_Room }>((action) => {
|
||||||
const roomId = action.payload.roomInfo.roomId.toString();
|
const roomId = action.payload.roomInfo.roomId.toString();
|
||||||
navigate(generatePath(App.RouteEnum.ROOM, { roomId }));
|
navigate(generatePath(App.RouteEnum.ROOM, { roomId }));
|
||||||
}, RoomsTypes.JOIN_ROOM, []);
|
}, RoomsTypes.JOIN_ROOM, []);
|
||||||
|
|
||||||
|
const userItems = useMemo(
|
||||||
|
() => users.map((user) => (
|
||||||
|
<ListItemButton key={user.name} dense>
|
||||||
|
<UserDisplay user={user} />
|
||||||
|
</ListItemButton>
|
||||||
|
)),
|
||||||
|
[users],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className="server-rooms">
|
<Layout className="server-rooms">
|
||||||
<AuthGuard />
|
<AuthGuard />
|
||||||
|
|
@ -49,13 +57,7 @@ const Server = () => {
|
||||||
<div className="server-rooms__side-label">
|
<div className="server-rooms__side-label">
|
||||||
Users connected to server: {users.length}
|
Users connected to server: {users.length}
|
||||||
</div>
|
</div>
|
||||||
<VirtualList
|
<VirtualList items={userItems} />
|
||||||
items={ users.map(user => (
|
|
||||||
<ListItemButton key={user.name} dense>
|
|
||||||
<UserDisplay user={user} />
|
|
||||||
</ListItemButton>
|
|
||||||
)) }
|
|
||||||
/>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,3 @@
|
||||||
.dialog-title {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.MuiDialogTitle-root.dialog-title {
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
margin-bottom: 20px;
|
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 Typography from '@mui/material/Typography';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { AccountActivationFormValues } from '@app/forms';
|
||||||
import { AccountActivationForm } from '@app/forms';
|
import { AccountActivationForm } from '@app/forms';
|
||||||
|
|
||||||
|
import AuthDialogShell from '../AuthDialogShell/AuthDialogShell';
|
||||||
|
|
||||||
import './AccountActivationDialog.css';
|
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 { t } = useTranslation();
|
||||||
|
|
||||||
const handleOnClose = () => {
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onClose={handleOnClose} open={isOpen}>
|
<AuthDialogShell
|
||||||
<DialogTitle className="dialog-title">
|
isOpen={isOpen}
|
||||||
<Typography variant="h6">{ t('AccountActivationDialog.title') }</Typography>
|
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 ? (
|
<AccountActivationForm onSubmit={onSubmit} />
|
||||||
<IconButton onClick={handleOnClose} size="large">
|
</AuthDialogShell>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,7 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
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 Dialog from '@mui/material/Dialog';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
|
@ -10,24 +9,23 @@ import { CardImportForm } from '@app/forms';
|
||||||
|
|
||||||
import './CardImportDialog.css';
|
import './CardImportDialog.css';
|
||||||
|
|
||||||
const CardImportDialog = ({ handleClose, isOpen }: any) => {
|
export interface CardImportDialogProps {
|
||||||
const handleOnClose = () => {
|
isOpen: boolean;
|
||||||
handleClose();
|
handleClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CardImportDialog = ({ handleClose, isOpen }: CardImportDialogProps) => {
|
||||||
return (
|
return (
|
||||||
<Dialog onClose={handleOnClose} open={isOpen}>
|
<Dialog onClose={handleClose} open={isOpen}>
|
||||||
<DialogTitle className="dialog-title">
|
<DialogTitle className="dialog-title">
|
||||||
<Typography variant="h2">Import Cards</Typography>
|
<Typography variant="h2">Import Cards</Typography>
|
||||||
|
|
||||||
{handleOnClose ? (
|
<IconButton onClick={handleClose} size="large">
|
||||||
<IconButton onClick={handleOnClose} size="large">
|
<CloseIcon />
|
||||||
<CloseIcon />
|
</IconButton>
|
||||||
</IconButton>
|
|
||||||
) : null}
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<CardImportForm onSubmit={handleOnClose}></CardImportForm>
|
<CardImportForm onSubmit={handleClose} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,20 @@ function CreateCounterDialog({ isOpen, onSubmit, onCancel }: CreateCounterDialog
|
||||||
helperText={error ?? ''}
|
helperText={error ?? ''}
|
||||||
slotProps={{ htmlInput: { 'aria-label': 'Counter name' } }}
|
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) => (
|
{SWATCHES.map((s, idx) => (
|
||||||
<button
|
<button
|
||||||
key={s.label}
|
key={s.label}
|
||||||
|
|
@ -96,6 +109,7 @@ function CreateCounterDialog({ isOpen, onSubmit, onCancel }: CreateCounterDialog
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={idx === selectedIdx}
|
aria-checked={idx === selectedIdx}
|
||||||
aria-label={s.label}
|
aria-label={s.label}
|
||||||
|
tabIndex={idx === selectedIdx ? 0 : -1}
|
||||||
className={cx('create-counter-dialog__swatch', {
|
className={cx('create-counter-dialog__swatch', {
|
||||||
'create-counter-dialog__swatch--selected': idx === selectedIdx,
|
'create-counter-dialog__swatch--selected': idx === selectedIdx,
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,44 @@
|
||||||
.create-token-dialog__body {
|
.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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
flex: 1 1 auto;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ import Select from '@mui/material/Select';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
import InputLabel from '@mui/material/InputLabel';
|
import InputLabel from '@mui/material/InputLabel';
|
||||||
import FormControl from '@mui/material/FormControl';
|
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 {
|
import {
|
||||||
MAX_ANNOTATION_LEN,
|
MAX_ANNOTATION_LEN,
|
||||||
|
|
@ -43,12 +48,15 @@ export interface CreateTokenSubmit {
|
||||||
annotation: string;
|
annotation: string;
|
||||||
destroyOnZoneChange: boolean;
|
destroyOnZoneChange: boolean;
|
||||||
faceDown: boolean;
|
faceDown: boolean;
|
||||||
|
providerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateTokenDialogProps {
|
export interface CreateTokenDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onSubmit: (args: CreateTokenSubmit) => void;
|
onSubmit: (args: CreateTokenSubmit) => void;
|
||||||
onCancel: () => 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
|
// Matches desktop DlgCreateToken color dropdown values. Desktop orders
|
||||||
|
|
@ -64,7 +72,7 @@ const COLOR_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
|
||||||
{ value: '', label: 'Colorless' },
|
{ value: '', label: 'Colorless' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function CreateTokenDialog({ isOpen, onSubmit, onCancel }: CreateTokenDialogProps) {
|
function CreateTokenDialog({ isOpen, onSubmit, onCancel, predefinedTokenNames }: CreateTokenDialogProps) {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
|
|
@ -73,6 +81,13 @@ function CreateTokenDialog({ isOpen, onSubmit, onCancel }: CreateTokenDialogProp
|
||||||
destroyOnZoneChange,
|
destroyOnZoneChange,
|
||||||
faceDown,
|
faceDown,
|
||||||
error,
|
error,
|
||||||
|
scope,
|
||||||
|
search,
|
||||||
|
filteredTokens,
|
||||||
|
selectedTokenName,
|
||||||
|
setScope,
|
||||||
|
setSearch,
|
||||||
|
selectPredefinedToken,
|
||||||
handleNameChange,
|
handleNameChange,
|
||||||
setColor,
|
setColor,
|
||||||
setPT,
|
setPT,
|
||||||
|
|
@ -80,7 +95,9 @@ function CreateTokenDialog({ isOpen, onSubmit, onCancel }: CreateTokenDialogProp
|
||||||
setDestroyOnZoneChange,
|
setDestroyOnZoneChange,
|
||||||
setFaceDown,
|
setFaceDown,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
} = useCreateTokenDialog({ isOpen, onSubmit });
|
} = useCreateTokenDialog({ isOpen, onSubmit, predefinedTokenNames });
|
||||||
|
|
||||||
|
const hasDeckScope = Boolean(predefinedTokenNames?.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledDialog
|
<StyledDialog
|
||||||
|
|
@ -96,74 +113,134 @@ function CreateTokenDialog({ isOpen, onSubmit, onCancel }: CreateTokenDialogProp
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<DialogContent className="dialog-content create-token-dialog__body">
|
<DialogContent className="dialog-content create-token-dialog__body">
|
||||||
<TextField
|
<div className="create-token-dialog__chooser">
|
||||||
autoFocus
|
<RadioGroup
|
||||||
fullWidth
|
row
|
||||||
variant="outlined"
|
value={scope}
|
||||||
size="small"
|
onChange={(e) => setScope(e.target.value as 'all' | 'deck')}
|
||||||
label="Token name"
|
aria-label="Token source"
|
||||||
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' } }}
|
|
||||||
>
|
>
|
||||||
{COLOR_OPTIONS.map((opt) => (
|
<FormControlLabel value="all" control={<Radio size="small" />} label="All Tokens" />
|
||||||
<MenuItem key={opt.label} value={opt.value}>
|
<FormControlLabel
|
||||||
{opt.label}
|
value="deck"
|
||||||
</MenuItem>
|
control={<Radio size="small" />}
|
||||||
))}
|
label="Deck Tokens"
|
||||||
</Select>
|
disabled={!hasDeckScope}
|
||||||
</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' } }}
|
|
||||||
/>
|
/>
|
||||||
}
|
</RadioGroup>
|
||||||
label="Destroy when it leaves the table"
|
<TextField
|
||||||
/>
|
fullWidth
|
||||||
<FormControlLabel
|
variant="outlined"
|
||||||
control={
|
size="small"
|
||||||
<Checkbox
|
label="Search tokens"
|
||||||
checked={faceDown}
|
value={search}
|
||||||
onChange={(e) => setFaceDown(e.target.checked)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
slotProps={{ input: { 'aria-label': 'Create face-down' } }}
|
slotProps={{ htmlInput: { 'aria-label': 'Search tokens' } }}
|
||||||
/>
|
/>
|
||||||
}
|
<div className="create-token-dialog__chooser-list">
|
||||||
label="Create face-down"
|
{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>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button type="button" onClick={onCancel}>Cancel</Button>
|
<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';
|
import type { CreateTokenSubmit } from './CreateTokenDialog';
|
||||||
|
|
||||||
|
export type ChooserScope = 'all' | 'deck';
|
||||||
|
|
||||||
export interface CreateTokenDialogState {
|
export interface CreateTokenDialogState {
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
|
@ -10,6 +14,17 @@ export interface CreateTokenDialogState {
|
||||||
destroyOnZoneChange: boolean;
|
destroyOnZoneChange: boolean;
|
||||||
faceDown: boolean;
|
faceDown: boolean;
|
||||||
error: string | null;
|
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;
|
handleNameChange: (value: string) => void;
|
||||||
setColor: (value: string) => void;
|
setColor: (value: string) => void;
|
||||||
setPT: (value: string) => void;
|
setPT: (value: string) => void;
|
||||||
|
|
@ -31,11 +46,39 @@ export const MAX_ANNOTATION_LEN = 255;
|
||||||
export interface UseCreateTokenDialogArgs {
|
export interface UseCreateTokenDialogArgs {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onSubmit: (args: CreateTokenSubmit) => void;
|
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({
|
export function useCreateTokenDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
predefinedTokenNames,
|
||||||
}: UseCreateTokenDialogArgs): CreateTokenDialogState {
|
}: UseCreateTokenDialogArgs): CreateTokenDialogState {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [color, setColor] = useState(CREATE_TOKEN_DEFAULT_COLOR);
|
const [color, setColor] = useState(CREATE_TOKEN_DEFAULT_COLOR);
|
||||||
|
|
@ -45,6 +88,12 @@ export function useCreateTokenDialog({
|
||||||
const [faceDown, setFaceDown] = useState(false);
|
const [faceDown, setFaceDown] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setName('');
|
setName('');
|
||||||
|
|
@ -54,9 +103,53 @@ export function useCreateTokenDialog({
|
||||||
setDestroyOnZoneChange(true);
|
setDestroyOnZoneChange(true);
|
||||||
setFaceDown(false);
|
setFaceDown(false);
|
||||||
setError(null);
|
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]);
|
}, [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) => {
|
const handleNameChange = (value: string) => {
|
||||||
setName(value.slice(0, MAX_NAME_LEN));
|
setName(value.slice(0, MAX_NAME_LEN));
|
||||||
if (error) {
|
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>) => {
|
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if (name.trim().length === 0) {
|
if (name.trim().length === 0) {
|
||||||
setError('Name is required');
|
setError('Name is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSubmit({
|
const payload: CreateTokenSubmit = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
color,
|
color,
|
||||||
pt: pt.trim(),
|
pt: pt.trim(),
|
||||||
annotation: annotation.trim(),
|
annotation: annotation.trim(),
|
||||||
destroyOnZoneChange,
|
destroyOnZoneChange,
|
||||||
faceDown,
|
faceDown,
|
||||||
});
|
};
|
||||||
|
if (providerId) {
|
||||||
|
payload.providerId = providerId;
|
||||||
|
}
|
||||||
|
onSubmit(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -88,6 +198,14 @@ export function useCreateTokenDialog({
|
||||||
destroyOnZoneChange,
|
destroyOnZoneChange,
|
||||||
faceDown,
|
faceDown,
|
||||||
error,
|
error,
|
||||||
|
scope,
|
||||||
|
search,
|
||||||
|
availableTokens,
|
||||||
|
filteredTokens,
|
||||||
|
selectedTokenName,
|
||||||
|
setScope,
|
||||||
|
setSearch,
|
||||||
|
selectPredefinedToken,
|
||||||
handleNameChange,
|
handleNameChange,
|
||||||
setColor,
|
setColor,
|
||||||
setPT,
|
setPT,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,13 @@ import './FilterGamesDialog.css';
|
||||||
|
|
||||||
export interface FilterGamesDialogProps {
|
export interface FilterGamesDialogProps {
|
||||||
isOpen: boolean;
|
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;
|
initialFilters: GameFilters;
|
||||||
gametypeMap: Enriched.GametypeMap;
|
gametypeMap: Enriched.GametypeMap;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,8 @@
|
||||||
.KnownHostDialog {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.KnownHostDialog .MuiDialog-paper {
|
.KnownHostDialog .MuiDialog-paper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
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 {
|
.dialog-content__subtitle.MuiTypography-root {
|
||||||
margin-bottom: 20px;
|
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 Typography from '@mui/material/Typography';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { KnownHostFormValues } from '@app/forms';
|
||||||
import { KnownHostForm } from '@app/forms';
|
import { KnownHostForm } from '@app/forms';
|
||||||
|
import type { HostDTO } from '@app/services';
|
||||||
|
|
||||||
|
import AuthDialogShell from '../AuthDialogShell/AuthDialogShell';
|
||||||
|
|
||||||
import './KnownHostDialog.css';
|
import './KnownHostDialog.css';
|
||||||
|
|
||||||
const PREFIX = 'KnownHostDialog';
|
interface KnownHostDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
handleClose?: () => void;
|
||||||
|
onRemove: (host: HostDTO) => void;
|
||||||
|
onSubmit: (values: KnownHostFormValues) => void;
|
||||||
|
host?: HostDTO;
|
||||||
|
}
|
||||||
|
|
||||||
const classes = {
|
const KnownHostDialog = ({ handleClose, onRemove, onSubmit, isOpen, host }: KnownHostDialogProps) => {
|
||||||
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 { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const mode = host ? 'edit' : 'add';
|
const mode = host ? 'edit' : 'add';
|
||||||
|
|
||||||
const handleOnClose = () => {
|
|
||||||
if (handleClose) {
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledDialog className={'KnownHostDialog ' + classes.root} onClose={handleOnClose} open={isOpen}>
|
<AuthDialogShell
|
||||||
<DialogTitle className='dialog-title'>
|
className="KnownHostDialog"
|
||||||
<div className='dialog-title__wrapper'>
|
contentClassName="dialog-content"
|
||||||
<Typography variant='h2'>{ t('KnownHostDialog.title', { mode }) }</Typography>
|
isOpen={isOpen}
|
||||||
|
handleClose={handleClose}
|
||||||
{handleClose ? (
|
title={t('KnownHostDialog.title', { mode })}
|
||||||
<IconButton onClick={handleClose} size="large">
|
>
|
||||||
<CloseIcon fontSize='large' />
|
<Typography className="dialog-content__subtitle" variant="subtitle1">
|
||||||
</IconButton>
|
{t('KnownHostDialog.subtitle')}
|
||||||
) : null}
|
</Typography>
|
||||||
</div>
|
<KnownHostForm onRemove={onRemove} onSubmit={onSubmit} host={host} />
|
||||||
</DialogTitle>
|
</AuthDialogShell>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
export interface PromptDialog {
|
export interface PromptDialogHandle {
|
||||||
value: string;
|
value: string;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
handleChange: (v: string) => void;
|
handleChange: (v: string) => void;
|
||||||
|
|
@ -19,7 +19,7 @@ export function usePromptDialog({
|
||||||
initialValue,
|
initialValue,
|
||||||
validate,
|
validate,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: UsePromptDialogArgs): PromptDialog {
|
}: UsePromptDialogArgs): PromptDialogHandle {
|
||||||
const [value, setValue] = useState(initialValue);
|
const [value, setValue] = useState(initialValue);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
.dialog-title {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-content {
|
.dialog-content {
|
||||||
width: 700px;
|
width: 700px;
|
||||||
max-width: 100%;
|
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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { RegisterFormValues } from '@app/forms';
|
||||||
import { RegisterForm } from '@app/forms';
|
import { RegisterForm } from '@app/forms';
|
||||||
|
|
||||||
|
import AuthDialogShell from '../AuthDialogShell/AuthDialogShell';
|
||||||
|
|
||||||
import './RegistrationDialog.css';
|
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 { t } = useTranslation();
|
||||||
|
|
||||||
const handleOnClose = () => {
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog className="RegistrationDialog" onClose={handleOnClose} open={isOpen} maxWidth='xl'>
|
<AuthDialogShell
|
||||||
<DialogTitle className="dialog-title">
|
className="RegistrationDialog"
|
||||||
<Typography variant="h6">{ t('RegistrationDialog.title') }</Typography>
|
contentClassName="dialog-content"
|
||||||
|
isOpen={isOpen}
|
||||||
{handleOnClose ? (
|
handleClose={handleClose}
|
||||||
<IconButton onClick={handleOnClose} size="large">
|
title={t('RegistrationDialog.title')}
|
||||||
<CloseIcon />
|
maxWidth="xl"
|
||||||
</IconButton>
|
>
|
||||||
) : null}
|
<RegisterForm onSubmit={onSubmit} />
|
||||||
</DialogTitle>
|
</AuthDialogShell>
|
||||||
<DialogContent className="dialog-content">
|
|
||||||
<RegisterForm onSubmit={onSubmit}></RegisterForm>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { RequestPasswordResetFormValues } from '@app/forms';
|
||||||
import { RequestPasswordResetForm } 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 { t } = useTranslation();
|
||||||
|
|
||||||
const handleOnClose = () => {
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onClose={handleOnClose} open={isOpen}>
|
<AuthDialogShell
|
||||||
<DialogTitle className="dialog-title">
|
isOpen={isOpen}
|
||||||
<Typography variant="h6">{ t('RequestPasswordResetDialog.title') }</Typography>
|
handleClose={handleClose}
|
||||||
|
title={t('RequestPasswordResetDialog.title')}
|
||||||
{handleOnClose ? (
|
>
|
||||||
<IconButton onClick={handleOnClose} size="large">
|
<RequestPasswordResetForm onSubmit={onSubmit} skipTokenRequest={skipTokenRequest} />
|
||||||
<CloseIcon />
|
</AuthDialogShell>
|
||||||
</IconButton>
|
|
||||||
) : null}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<RequestPasswordResetForm onSubmit={onSubmit} skipTokenRequest={skipTokenRequest}></RequestPasswordResetForm>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { ResetPasswordFormValues } from '@app/forms';
|
||||||
import { ResetPasswordForm } 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 { t } = useTranslation();
|
||||||
|
|
||||||
const handleOnClose = () => {
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onClose={handleOnClose} open={isOpen}>
|
<AuthDialogShell
|
||||||
<DialogTitle className="dialog-title">
|
isOpen={isOpen}
|
||||||
<Typography variant="h6">{t('ResetPasswordDialog.title')}</Typography>
|
handleClose={handleClose}
|
||||||
|
title={t('ResetPasswordDialog.title')}
|
||||||
{handleOnClose ? (
|
>
|
||||||
<IconButton onClick={handleOnClose} size="large">
|
<ResetPasswordForm onSubmit={onSubmit} userName={userName} />
|
||||||
<CloseIcon />
|
</AuthDialogShell>
|
||||||
</IconButton>
|
|
||||||
) : null}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<ResetPasswordForm onSubmit={onSubmit} userName={userName}/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import Typography from '@mui/material/Typography';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
import Checkbox from '@mui/material/Checkbox';
|
import Checkbox from '@mui/material/Checkbox';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
|
||||||
import { App, Enriched } from '@app/types';
|
import { App, Enriched } from '@app/types';
|
||||||
|
|
||||||
|
|
@ -84,19 +85,14 @@ function SideboardDialog({
|
||||||
}: SideboardDialogProps) {
|
}: SideboardDialogProps) {
|
||||||
const [moves, setMoves] = useState<SideboardPlanMove[]>([]);
|
const [moves, setMoves] = useState<SideboardPlanMove[]>([]);
|
||||||
|
|
||||||
// Reset the draft every time the dialog opens, and also when the server
|
// Reset the draft whenever the dialog opens, or when the server locks the
|
||||||
// locks the sideboard mid-edit (desktop's resetSideboardPlan parity).
|
// sideboard mid-edit (desktop's resetSideboardPlan parity). Consolidated
|
||||||
|
// into one effect keyed on both triggers.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen || isLocked) {
|
||||||
setMoves([]);
|
setMoves([]);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen, isLocked]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLocked && moves.length > 0) {
|
|
||||||
setMoves([]);
|
|
||||||
}
|
|
||||||
}, [isLocked, moves.length]);
|
|
||||||
|
|
||||||
const { deck, sideboard } = useMemo(
|
const { deck, sideboard } = useMemo(
|
||||||
() => applyMoves(deckCards, sideboardCards, moves),
|
() => applyMoves(deckCards, sideboardCards, moves),
|
||||||
|
|
@ -166,15 +162,19 @@ function SideboardDialog({
|
||||||
{deck.map((card, idx) => (
|
{deck.map((card, idx) => (
|
||||||
<li key={`${card.id}-${idx}`} className="sideboard-dialog__row">
|
<li key={`${card.id}-${idx}`} className="sideboard-dialog__row">
|
||||||
<span className="sideboard-dialog__name">{card.name}</span>
|
<span className="sideboard-dialog__name">{card.name}</span>
|
||||||
<Button
|
<Tooltip title={`Move ${card.name} to sideboard`}>
|
||||||
type="button"
|
<span>
|
||||||
size="small"
|
<Button
|
||||||
onClick={() => handleMoveToSideboard(card)}
|
type="button"
|
||||||
disabled={isLocked}
|
size="small"
|
||||||
aria-label={`Move ${card.name} to sideboard`}
|
onClick={() => handleMoveToSideboard(card)}
|
||||||
>
|
disabled={isLocked}
|
||||||
→
|
aria-label={`Move ${card.name} to sideboard`}
|
||||||
</Button>
|
>
|
||||||
|
→
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{deck.length === 0 && (
|
{deck.length === 0 && (
|
||||||
|
|
@ -192,15 +192,19 @@ function SideboardDialog({
|
||||||
<ul className="sideboard-dialog__list" data-testid="sideboard-dialog-sb">
|
<ul className="sideboard-dialog__list" data-testid="sideboard-dialog-sb">
|
||||||
{sideboard.map((card, idx) => (
|
{sideboard.map((card, idx) => (
|
||||||
<li key={`${card.id}-${idx}`} className="sideboard-dialog__row">
|
<li key={`${card.id}-${idx}`} className="sideboard-dialog__row">
|
||||||
<Button
|
<Tooltip title={`Move ${card.name} to main deck`}>
|
||||||
type="button"
|
<span>
|
||||||
size="small"
|
<Button
|
||||||
onClick={() => handleMoveToDeck(card)}
|
type="button"
|
||||||
disabled={isLocked}
|
size="small"
|
||||||
aria-label={`Move ${card.name} to main deck`}
|
onClick={() => handleMoveToDeck(card)}
|
||||||
>
|
disabled={isLocked}
|
||||||
←
|
aria-label={`Move ${card.name} to main deck`}
|
||||||
</Button>
|
>
|
||||||
|
←
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
<span className="sideboard-dialog__name">{card.name}</span>
|
<span className="sideboard-dialog__name">{card.name}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
export { default as AccountActivationDialog } from './AccountActivationDialog/AccountActivationDialog';
|
export { default as AccountActivationDialog } from './AccountActivationDialog/AccountActivationDialog';
|
||||||
export { default as AlertDialog } from './AlertDialog/AlertDialog';
|
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 type { AlertDialogProps, AlertDialogSeverity } from './AlertDialog/AlertDialog';
|
||||||
export { default as CardImportDialog } from './CardImportDialog/CardImportDialog';
|
export { default as CardImportDialog } from './CardImportDialog/CardImportDialog';
|
||||||
export { default as ConfirmDialog } from './ConfirmDialog/ConfirmDialog';
|
export { default as ConfirmDialog } from './ConfirmDialog/ConfirmDialog';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
// eslint-disable-next-line
|
import { useState } from 'react';
|
||||||
import React, { useState } from "react";
|
|
||||||
import { Form, Field } from 'react-final-form';
|
import { Form, Field } from 'react-final-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
|
@ -7,12 +6,21 @@ import Button from '@mui/material/Button';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
import { InputField } from '@app/components';
|
import { InputField } from '@app/components';
|
||||||
|
import type { FormErrors } from '@app/forms';
|
||||||
import { useReduxEffect } from '@app/hooks';
|
import { useReduxEffect } from '@app/hooks';
|
||||||
import { ServerTypes } from '@app/store';
|
import { ServerTypes } from '@app/store';
|
||||||
|
|
||||||
import './AccountActivationForm.css';
|
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 [errorMessage, setErrorMessage] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|
@ -20,16 +28,14 @@ const AccountActivationForm = ({ onSubmit }) => {
|
||||||
setErrorMessage(true);
|
setErrorMessage(true);
|
||||||
}, ServerTypes.ACCOUNT_ACTIVATION_FAILED, []);
|
}, ServerTypes.ACCOUNT_ACTIVATION_FAILED, []);
|
||||||
|
|
||||||
const handleOnSubmit = ({ token, ...values }) => {
|
const handleOnSubmit = ({ token, ...values }: AccountActivationFormValues) => {
|
||||||
setErrorMessage(false);
|
setErrorMessage(false);
|
||||||
|
|
||||||
token = token?.trim();
|
onSubmit({ ...values, token: token?.trim() });
|
||||||
|
};
|
||||||
|
|
||||||
onSubmit({ token, ...values });
|
const validate = (values: Partial<AccountActivationFormValues>): FormErrors<AccountActivationFormValues> => {
|
||||||
}
|
const errors: FormErrors<AccountActivationFormValues> = {};
|
||||||
|
|
||||||
const validate = values => {
|
|
||||||
const errors: any = {};
|
|
||||||
|
|
||||||
if (!values.token) {
|
if (!values.token) {
|
||||||
errors.token = t('Common.validation.required');
|
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 { Form, Field } from 'react-final-form';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Stepper from '@mui/material/Stepper';
|
import Stepper from '@mui/material/Stepper';
|
||||||
|
|
@ -7,12 +9,72 @@ import StepLabel from '@mui/material/StepLabel';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
import { InputField, VirtualList } from '@app/components';
|
import { InputField, VirtualList } from '@app/components';
|
||||||
|
import type { App } from '@app/types';
|
||||||
|
|
||||||
import { useCardImportForm } from './useCardImportForm';
|
import { useCardImportForm } from './useCardImportForm';
|
||||||
|
|
||||||
import './CardImportForm.css';
|
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 {
|
const {
|
||||||
loading,
|
loading,
|
||||||
activeStep,
|
activeStep,
|
||||||
|
|
@ -25,24 +87,29 @@ const CardImportForm = ({ onSubmit: onClose }) => {
|
||||||
handleTokenDownload,
|
handleTokenDownload,
|
||||||
} = useCardImportForm();
|
} = 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) {
|
switch (stepIndex) {
|
||||||
case 0: return (
|
case 0: return (
|
||||||
<Form
|
<Form
|
||||||
onSubmit={handleCardDownload}
|
onSubmit={handleCardDownload}
|
||||||
initialValues={{ cardDownloadUrl: 'https://www.mtgjson.com/api/v5/AllPrintings.json' }}
|
initialValues={{ cardDownloadUrl: CARDS_URL }}
|
||||||
>
|
>
|
||||||
{({ handleSubmit }) => (
|
{({ handleSubmit }) => (
|
||||||
<form className='cardImportForm' onSubmit={handleSubmit}>
|
<form className='cardImportForm' onSubmit={handleSubmit}>
|
||||||
<div className='cardImportForm-item'>
|
<div className='cardImportForm-item'>
|
||||||
<Field label='Download URL' name='cardDownloadUrl' component={InputField} />
|
<Field label={t('CardImportForm.label.downloadUrl')} name='cardDownloadUrl' component={InputField} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='cardImportForm-actions'>
|
<div className='cardImportForm-actions'>
|
||||||
<Button color='primary' type='submit' disabled={loading}>
|
<Button color='primary' type='submit' disabled={loading}>
|
||||||
Import
|
{t('CardImportForm.button.import')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -63,7 +130,7 @@ const CardImportForm = ({ onSubmit: onClose }) => {
|
||||||
<div className='cardImportForm-actions'>
|
<div className='cardImportForm-actions'>
|
||||||
<BackButton click={handleBack} disabled={loading} />
|
<BackButton click={handleBack} disabled={loading} />
|
||||||
<Button color='primary' onClick={handleCardSave} disabled={loading}>
|
<Button color='primary' onClick={handleCardSave} disabled={loading}>
|
||||||
Save
|
{t('CardImportForm.button.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -76,18 +143,18 @@ const CardImportForm = ({ onSubmit: onClose }) => {
|
||||||
case 2: return (
|
case 2: return (
|
||||||
<Form
|
<Form
|
||||||
onSubmit={handleTokenDownload}
|
onSubmit={handleTokenDownload}
|
||||||
initialValues={{ tokenDownloadUrl: 'https://raw.githubusercontent.com/Cockatrice/Magic-Token/master/tokens.xml' }}
|
initialValues={{ tokenDownloadUrl: TOKENS_URL }}
|
||||||
>
|
>
|
||||||
{({ handleSubmit }) => (
|
{({ handleSubmit }) => (
|
||||||
<form className='cardImportForm' onSubmit={handleSubmit}>
|
<form className='cardImportForm' onSubmit={handleSubmit}>
|
||||||
<div className='cardImportForm-content'>
|
<div className='cardImportForm-content'>
|
||||||
<Field label='Download URL' name='tokenDownloadUrl' component={InputField} />
|
<Field label={t('CardImportForm.label.downloadUrl')} name='tokenDownloadUrl' component={InputField} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='cardImportForm-actions'>
|
<div className='cardImportForm-actions'>
|
||||||
<BackButton click={handleBack} disabled={loading} />
|
<BackButton click={handleBack} disabled={loading} />
|
||||||
<Button color='primary' type='submit' disabled={loading}>
|
<Button color='primary' type='submit' disabled={loading}>
|
||||||
Import
|
{t('CardImportForm.button.import')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -101,14 +168,17 @@ const CardImportForm = ({ onSubmit: onClose }) => {
|
||||||
|
|
||||||
case 3: return (
|
case 3: return (
|
||||||
<div className='cardImportForm'>
|
<div className='cardImportForm'>
|
||||||
<div className='cardImportForm-content done'>Finished!</div>
|
<div className='cardImportForm-content done'>{t('CardImportForm.message.finished')}</div>
|
||||||
|
|
||||||
<div className='cardImportForm-actions'>
|
<div className='cardImportForm-actions'>
|
||||||
<BackButton click={handleBack} disabled={loading} />
|
<BackButton click={handleBack} disabled={loading} />
|
||||||
<Button color='primary' onClick={onClose}>Done</Button>
|
<Button color='primary' onClick={onClose}>{t('CardImportForm.button.done')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</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;
|
export default CardImportForm;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { cardImporterService, CardDTO, SetDTO, TokenDTO } from '@app/services';
|
import { cardImporterService, CardDTO, SetDTO, TokenDTO } from '@app/services';
|
||||||
|
import type { App } from '@app/types';
|
||||||
|
|
||||||
export interface CardImportForm {
|
export interface CardImportForm {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
activeStep: number;
|
activeStep: number;
|
||||||
importedCards: any[];
|
importedCards: App.Card[];
|
||||||
importedSets: any[];
|
importedSets: App.Set[];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
handleNext: () => void;
|
handleNext: () => void;
|
||||||
handleBack: () => void;
|
handleBack: () => void;
|
||||||
|
|
@ -18,8 +19,8 @@ export interface CardImportForm {
|
||||||
export function useCardImportForm(): CardImportForm {
|
export function useCardImportForm(): CardImportForm {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [activeStep, setActiveStep] = useState(0);
|
const [activeStep, setActiveStep] = useState(0);
|
||||||
const [importedCards, setImportedCards] = useState<any[]>([]);
|
const [importedCards, setImportedCards] = useState<App.Card[]>([]);
|
||||||
const [importedSets, setImportedSets] = useState<any[]>([]);
|
const [importedSets, setImportedSets] = useState<App.Set[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,35 @@
|
||||||
// eslint-disable-next-line
|
import { useState } from 'react';
|
||||||
import React, { useState } from "react";
|
import { Form, Field } from 'react-final-form';
|
||||||
import { Form, Field } from 'react-final-form'
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import AnchorLink from '@mui/material/Link';
|
import AnchorLink from '@mui/material/Link';
|
||||||
|
|
||||||
import { InputField } from '@app/components';
|
import { InputField } from '@app/components';
|
||||||
|
import type { FormErrors } from '@app/forms';
|
||||||
|
import type { HostDTO } from '@app/services';
|
||||||
|
|
||||||
import './KnownHostForm.css';
|
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 [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const validate = values => {
|
const validate = (values: Partial<KnownHostFormValues>): FormErrors<KnownHostFormValues> => {
|
||||||
const errors: any = {};
|
const errors: FormErrors<KnownHostFormValues> = {};
|
||||||
|
|
||||||
if (!values.name) {
|
if (!values.name) {
|
||||||
errors.name = t('Common.validation.required');
|
errors.name = t('Common.validation.required');
|
||||||
|
|
@ -29,17 +43,27 @@ const KnownHostForm = ({ host, onRemove, onSubmit }) => {
|
||||||
errors.port = t('Common.validation.required');
|
errors.port = t('Common.validation.required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(errors).length) {
|
return errors;
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOnSubmit = ({ name, host, ...values }) => {
|
const handleOnSubmit = ({ name, host: hostValue, ...values }: KnownHostFormValues) => {
|
||||||
name = name?.trim();
|
onSubmit({
|
||||||
host = host?.trim();
|
...values,
|
||||||
|
name: name?.trim(),
|
||||||
|
host: hostValue?.trim(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
onSubmit({ name, host, ...values });
|
const handleRemoveClick = () => {
|
||||||
}
|
if (!host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirmDelete) {
|
||||||
|
setConfirmDelete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onRemove(host);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
|
|
@ -71,7 +95,7 @@ const KnownHostForm = ({ host, onRemove, onSubmit }) => {
|
||||||
<div className="KnownHostForm-actions">
|
<div className="KnownHostForm-actions">
|
||||||
<div className="KnownHostForm-actions__delete">
|
<div className="KnownHostForm-actions__delete">
|
||||||
{ host && (
|
{ host && (
|
||||||
<Button color="inherit" onClick={() => !confirmDelete ? setConfirmDelete(true) : onRemove(host)}>
|
<Button color="inherit" onClick={handleRemoveClick}>
|
||||||
{ !confirmDelete ? t('Common.label.delete') : t('Common.label.confirmSure') }
|
{ !confirmDelete ? t('Common.label.delete') : t('Common.label.confirmSure') }
|
||||||
</Button>
|
</Button>
|
||||||
) }
|
) }
|
||||||
|
|
|
||||||
|
|
@ -18,3 +18,9 @@
|
||||||
.loginForm-submit {
|
.loginForm-submit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loginForm-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"forgot": "Forgot Password",
|
"forgot": "Forgot Password",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"savePassword": "Save Password",
|
"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 Button from '@mui/material/Button';
|
||||||
import Checkbox from '@mui/material/Checkbox';
|
import Checkbox from '@mui/material/Checkbox';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
|
||||||
import { CheckboxField, InputField, KnownHosts } from '@app/components';
|
import { CheckboxField, InputField, KnownHosts } from '@app/components';
|
||||||
|
import type { FormErrors } from '@app/forms';
|
||||||
import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
|
import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
|
||||||
|
import { HostDTO } from '@app/services';
|
||||||
|
|
||||||
import { useLoginFormBody } from './useLoginForm';
|
import { useLoginFormBody } from './useLoginForm';
|
||||||
|
|
||||||
import './LoginForm.css';
|
import './LoginForm.css';
|
||||||
|
|
||||||
|
export interface LoginFormValues {
|
||||||
|
userName: string;
|
||||||
|
password: string;
|
||||||
|
remember: boolean;
|
||||||
|
autoConnect: boolean;
|
||||||
|
selectedHost: HostDTO;
|
||||||
|
}
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
onSubmit: (values: any) => void;
|
onSubmit: (values: LoginFormValues) => void;
|
||||||
disableSubmitButton: boolean;
|
disableSubmitButton: boolean;
|
||||||
onResetPassword: () => void;
|
onResetPassword: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +44,7 @@ const LoginFormBody = ({
|
||||||
}: LoginFormBodyProps) => {
|
}: LoginFormBodyProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const PASSWORD_LABEL = t('Common.label.password');
|
const PASSWORD_LABEL = t('Common.label.password');
|
||||||
const STORED_PASSWORD_LABEL = `* ${t('LoginForm.label.savedPassword')} *`;
|
const STORED_PASSWORD_LABEL = t('LoginForm.label.savedPassword');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
useStoredPasswordLabel,
|
useStoredPasswordLabel,
|
||||||
|
|
@ -121,8 +132,8 @@ const LoginForm = (props: LoginFormProps) => {
|
||||||
const knownHosts = useKnownHosts();
|
const knownHosts = useKnownHosts();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
|
||||||
const validate = (values: any) => {
|
const validate = (values: Partial<LoginFormValues>): FormErrors<LoginFormValues> => {
|
||||||
const errors: any = {};
|
const errors: FormErrors<LoginFormValues> = {};
|
||||||
|
|
||||||
if (!values.userName) {
|
if (!values.userName) {
|
||||||
errors.userName = t('Common.validation.required');
|
errors.userName = t('Common.validation.required');
|
||||||
|
|
@ -134,17 +145,20 @@ const LoginForm = (props: LoginFormProps) => {
|
||||||
return errors;
|
return errors;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOnSubmit = ({ userName, ...values }: any) => {
|
const handleOnSubmit = ({ userName, ...values }: LoginFormValues) => {
|
||||||
userName = userName?.trim();
|
props.onSubmit({ ...values, userName: userName?.trim() });
|
||||||
props.onSubmit({ userName, ...values });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (knownHosts.status !== LoadingState.READY || settings.status !== LoadingState.READY) {
|
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 selectedHost = knownHosts.value?.selectedHost;
|
||||||
const initialValues = {
|
const initialValues: Partial<LoginFormValues> = {
|
||||||
selectedHost,
|
selectedHost,
|
||||||
userName: selectedHost?.userName ?? '',
|
userName: selectedHost?.userName ?? '',
|
||||||
remember: Boolean(selectedHost?.remember),
|
remember: Boolean(selectedHost?.remember),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect } from 'react';
|
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 { OnChange } from 'react-final-form-listeners';
|
||||||
import setFieldTouched from 'final-form-set-field-touched';
|
import setFieldTouched from 'final-form-set-field-touched';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
@ -8,12 +8,42 @@ import Button from '@mui/material/Button';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
import { CountryDropdown, InputField, KnownHosts } from '@app/components';
|
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 { ServerDispatch } from '@app/store';
|
||||||
|
|
||||||
import { useRegisterForm } from './useRegisterForm';
|
import { useRegisterForm } from './useRegisterForm';
|
||||||
|
|
||||||
import './RegisterForm.css';
|
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 RegisterForm = ({ onSubmit }: RegisterFormProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
|
|
@ -28,18 +58,19 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
|
||||||
onUserNameChange,
|
onUserNameChange,
|
||||||
} = useRegisterForm();
|
} = useRegisterForm();
|
||||||
|
|
||||||
const handleOnSubmit = ({ userName, email, realName, ...values }) => {
|
const handleOnSubmit = (values: RegisterFormValues) => {
|
||||||
ServerDispatch.clearRegistrationErrors();
|
ServerDispatch.clearRegistrationErrors();
|
||||||
|
|
||||||
userName = userName?.trim();
|
onSubmit({
|
||||||
email = email?.trim();
|
...values,
|
||||||
realName = realName?.trim();
|
userName: values.userName?.trim(),
|
||||||
|
email: values.email?.trim(),
|
||||||
onSubmit({ userName, email, realName, ...values });
|
realName: values.realName?.trim(),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const validate = values => {
|
const validate = (values: Partial<RegisterFormValues>): FormErrors<RegisterFormValues> => {
|
||||||
const errors: any = {};
|
const errors: FormErrors<RegisterFormValues> = {};
|
||||||
|
|
||||||
if (!values.userName) {
|
if (!values.userName) {
|
||||||
errors.userName = t('Common.validation.required');
|
errors.userName = t('Common.validation.required');
|
||||||
|
|
@ -71,83 +102,87 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
|
||||||
errors.email = emailError;
|
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 errors;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleOnSubmit} validate={validate} mutators={{ setFieldTouched }}>
|
<Form onSubmit={handleOnSubmit} validate={validate} mutators={{ setFieldTouched }}>
|
||||||
{({ handleSubmit, form }) => {
|
{({ handleSubmit }) => (
|
||||||
|
<>
|
||||||
useEffect(() => {
|
<EmailTouchOnRequire emailRequired={emailRequired} />
|
||||||
if (emailRequired) {
|
<form className="RegisterForm" onSubmit={handleSubmit}>
|
||||||
form.mutators.setFieldTouched('email', true);
|
<div className="RegisterForm-column">
|
||||||
}
|
|
||||||
}, [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 && (
|
|
||||||
<div className="RegisterForm-item">
|
<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>
|
||||||
)}
|
<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 >
|
</Form >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface RegisterFormProps {
|
|
||||||
onSubmit: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RegisterForm;
|
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 { OnChange } from 'react-final-form-listeners';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
|
@ -6,26 +7,44 @@ import Button from '@mui/material/Button';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
import { InputField, KnownHosts } from '@app/components';
|
import { InputField, KnownHosts } from '@app/components';
|
||||||
|
import type { FormErrors } from '@app/forms';
|
||||||
|
import { HostDTO } from '@app/services';
|
||||||
|
|
||||||
import { useRequestPasswordResetForm } from './useRequestPasswordResetForm';
|
import { useRequestPasswordResetForm } from './useRequestPasswordResetForm';
|
||||||
|
|
||||||
import './RequestPasswordResetForm.css';
|
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 { t } = useTranslation();
|
||||||
const { errorMessage, setErrorMessage, isMFA, setIsMFA } = useRequestPasswordResetForm();
|
const { errorMessage, setErrorMessage, isMFA, setIsMFA } = useRequestPasswordResetForm();
|
||||||
|
|
||||||
const handleOnSubmit = ({ userName, email, ...values }) => {
|
const handleOnSubmit = ({ userName, email, ...values }: RequestPasswordResetFormValues) => {
|
||||||
setErrorMessage(false);
|
setErrorMessage(false);
|
||||||
|
|
||||||
userName = userName?.trim();
|
onSubmit({
|
||||||
email = email?.trim();
|
...values,
|
||||||
|
userName: userName?.trim(),
|
||||||
onSubmit({ userName, email, ...values });
|
email: email?.trim(),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const validate = values => {
|
const validate = (values: Partial<RequestPasswordResetFormValues>): FormErrors<RequestPasswordResetFormValues> => {
|
||||||
const errors: any = {};
|
const errors: FormErrors<RequestPasswordResetFormValues> = {};
|
||||||
|
|
||||||
if (!values.userName) {
|
if (!values.userName) {
|
||||||
errors.userName = t('Common.validation.required');
|
errors.userName = t('Common.validation.required');
|
||||||
|
|
@ -42,50 +61,79 @@ const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleOnSubmit} validate={validate}>
|
<Form onSubmit={handleOnSubmit} validate={validate}>
|
||||||
{({ handleSubmit, form }) => {
|
{({ handleSubmit, form }) => (
|
||||||
const onHostChange: any = ({ userName }) => {
|
<RequestPasswordResetFormBody
|
||||||
form.change('userName', userName);
|
handleSubmit={handleSubmit}
|
||||||
setIsMFA(false);
|
form={form}
|
||||||
};
|
errorMessage={errorMessage}
|
||||||
|
isMFA={isMFA}
|
||||||
return (
|
setIsMFA={setIsMFA}
|
||||||
<form className="RequestPasswordResetForm" onSubmit={handleSubmit}>
|
skipTokenRequest={skipTokenRequest}
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Form>
|
</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;
|
export default RequestPasswordResetForm;
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,32 @@ import Button from '@mui/material/Button';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
import { InputField, KnownHosts } from '@app/components';
|
import { InputField, KnownHosts } from '@app/components';
|
||||||
|
import type { FormErrors } from '@app/forms';
|
||||||
|
import { HostDTO } from '@app/services';
|
||||||
|
|
||||||
import { useResetPasswordForm } from './useResetPasswordForm';
|
import { useResetPasswordForm } from './useResetPasswordForm';
|
||||||
|
|
||||||
import './ResetPasswordForm.css';
|
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 { t } = useTranslation();
|
||||||
const { errorMessage } = useResetPasswordForm();
|
const { errorMessage } = useResetPasswordForm();
|
||||||
|
|
||||||
const validate = values => {
|
const validate = (values: Partial<ResetPasswordFormValues>): FormErrors<ResetPasswordFormValues> => {
|
||||||
const errors: any = {};
|
const errors: FormErrors<ResetPasswordFormValues> = {};
|
||||||
|
|
||||||
if (!values.userName) {
|
if (!values.userName) {
|
||||||
errors.userName = t('Common.validation.required');
|
errors.userName = t('Common.validation.required');
|
||||||
|
|
@ -42,11 +57,12 @@ const ResetPasswordForm = ({ onSubmit, userName }) => {
|
||||||
return errors;
|
return errors;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOnSubmit = ({ userName, token, ...values }) => {
|
const handleOnSubmit = ({ userName: uName, token, ...values }: ResetPasswordFormValues) => {
|
||||||
userName = userName?.trim();
|
onSubmit({
|
||||||
token = token?.trim();
|
...values,
|
||||||
|
userName: uName?.trim(),
|
||||||
onSubmit({ userName, token, ...values });
|
token: token?.trim(),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -60,7 +76,7 @@ const ResetPasswordForm = ({ onSubmit, userName }) => {
|
||||||
name='userName'
|
name='userName'
|
||||||
component={InputField}
|
component={InputField}
|
||||||
autoComplete='username'
|
autoComplete='username'
|
||||||
disabled={!!userName}
|
InputProps={{ readOnly: Boolean(userName) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='ResetPasswordForm-item'>
|
<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 { Form, Field } from 'react-final-form';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Divider from '@mui/material/Divider';
|
import Divider from '@mui/material/Divider';
|
||||||
|
|
@ -10,48 +9,61 @@ import { InputField, CheckboxField } from '@app/components';
|
||||||
|
|
||||||
import './SearchForm.css';
|
import './SearchForm.css';
|
||||||
|
|
||||||
const SearchForm = ({ onSubmit }) => (
|
export interface SearchFormValues {
|
||||||
<Form onSubmit={onSubmit}>
|
userName?: string;
|
||||||
{({ handleSubmit }) => (
|
ipAddress?: string;
|
||||||
<Paper className="log-search">
|
gameName?: string;
|
||||||
<form className="log-search__form" onSubmit={handleSubmit}>
|
gameId?: string;
|
||||||
<div className="log-search__form-item">
|
message?: string;
|
||||||
<Field label="Username" name="userName" component={InputField} />
|
logLocation?: {
|
||||||
</div>
|
room?: boolean;
|
||||||
<div className="log-search__form-item">
|
game?: boolean;
|
||||||
<Field label="IP Address" name="ipAddress" component={InputField} />
|
chat?: boolean;
|
||||||
</div>
|
};
|
||||||
<div className="log-search__form-item">
|
}
|
||||||
<Field label="Game Name" name="gameName" component={InputField} />
|
|
||||||
</div>
|
interface SearchFormProps {
|
||||||
<div className="log-search__form-item">
|
onSubmit: (values: SearchFormValues) => void;
|
||||||
<Field label="GameID" name="gameId" component={InputField} />
|
}
|
||||||
</div>
|
|
||||||
<div className="log-search__form-item">
|
const SearchForm = ({ onSubmit }: SearchFormProps) => {
|
||||||
<Field label="Message" name="message" component={InputField} />
|
const { t } = useTranslation();
|
||||||
</div>
|
|
||||||
<Divider />
|
return (
|
||||||
<div className="log-search__form-item log-location">
|
<Form onSubmit={onSubmit}>
|
||||||
<Field label="Rooms" name="logLocation.room" component={CheckboxField} />
|
{({ handleSubmit }) => (
|
||||||
<Field label="Games" name="logLocation.game" component={CheckboxField} />
|
<Paper className="log-search">
|
||||||
<Field label="Chats" name="logLocation.chat" component={CheckboxField} />
|
<form className="log-search__form" onSubmit={handleSubmit}>
|
||||||
</div>
|
<div className="log-search__form-item">
|
||||||
<Divider />
|
<Field label={t('SearchForm.label.userName')} name="userName" component={InputField} />
|
||||||
<div className="log-search__form-item">
|
</div>
|
||||||
<span>Date Range: Coming Soon</span>
|
<div className="log-search__form-item">
|
||||||
</div>
|
<Field label={t('SearchForm.label.ipAddress')} name="ipAddress" component={InputField} />
|
||||||
<Divider />
|
</div>
|
||||||
<div className="log-search__form-item">
|
<div className="log-search__form-item">
|
||||||
<span>Maximum Results: 1000</span>
|
<Field label={t('SearchForm.label.gameName')} name="gameName" component={InputField} />
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
<div className="log-search__form-item">
|
||||||
<Button className="log-search__form-submit" color="primary" variant="contained" type="submit">
|
<Field label={t('SearchForm.label.gameId')} name="gameId" component={InputField} />
|
||||||
Search Logs
|
</div>
|
||||||
</Button>
|
<div className="log-search__form-item">
|
||||||
</form>
|
<Field label={t('SearchForm.label.message')} name="message" component={InputField} />
|
||||||
</Paper>
|
</div>
|
||||||
)}
|
<Divider />
|
||||||
</Form>
|
<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;
|
export default SearchForm;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
export type { FormErrors } from './types';
|
||||||
|
|
||||||
export { default as AccountActivationForm } from './AccountActivationForm/AccountActivationForm';
|
export { default as AccountActivationForm } from './AccountActivationForm/AccountActivationForm';
|
||||||
export { default as CardImportForm } from './CardImportForm/CardImportForm';
|
export { default as CardImportForm } from './CardImportForm/CardImportForm';
|
||||||
export { default as LoginForm } from './LoginForm/LoginForm';
|
export { default as LoginForm } from './LoginForm/LoginForm';
|
||||||
|
|
|
||||||
1
webclient/src/forms/types.ts
Normal file
1
webclient/src/forms/types.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export type FormErrors<T> = Partial<Record<keyof T, string>>;
|
||||||
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