mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-06-10 00:04:48 -07:00
Comprehensive review changes
This commit is contained in:
parent
3aa8c654cc
commit
6074d9d6e4
143 changed files with 2661 additions and 1535 deletions
|
|
@ -1,6 +1,3 @@
|
|||
// eslint-disable-next-line
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { CardDTO } from '@app/services';
|
||||
|
||||
import './Card.css';
|
||||
|
|
@ -10,11 +7,13 @@ interface CardProps {
|
|||
}
|
||||
|
||||
const Card = ({ card }: CardProps) => {
|
||||
const src = `https://api.scryfall.com/cards/${card?.identifiers?.scryfallId}?format=image`;
|
||||
if (!card) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return card && (
|
||||
<img className="card" src={src} alt={card?.name} />
|
||||
);
|
||||
}
|
||||
const src = `https://api.scryfall.com/cards/${card.identifiers?.scryfallId}?format=image`;
|
||||
|
||||
return <img className="card" src={src} alt={card.name} />;
|
||||
};
|
||||
|
||||
export default Card;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ const CardDetails = ({ card }: CardProps) => {
|
|||
(!card.power && !card.toughness) ? null : (
|
||||
<div className='cardDetails-attribute'>
|
||||
<span className='cardDetails-attribute__label'>P/T:</span>
|
||||
<span className='cardDetails-attribute__value'>{card.power || 0}/{card.toughness || 0}</span>
|
||||
<span className='cardDetails-attribute__value'>{card.power ?? 0}/{card.toughness ?? 0}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,28 @@
|
|||
import React from 'react';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
|
||||
const CheckboxField = (props) => {
|
||||
const { input: { value, onChange }, label, ...args } = props;
|
||||
import type { FinalFormFieldProps } from '../fieldTypes';
|
||||
|
||||
type CheckboxFieldProps = FinalFormFieldProps<boolean, HTMLInputElement> & {
|
||||
label?: string;
|
||||
} & Omit<CheckboxProps, 'checked' | 'onChange' | 'onBlur' | 'onFocus' | 'name' | 'value'>;
|
||||
|
||||
const CheckboxField = ({ input, meta: _meta, label, ...args }: CheckboxFieldProps) => {
|
||||
const { value, onChange, onBlur, onFocus, name } = input;
|
||||
|
||||
// @TODO this isnt unchecking properly
|
||||
return (
|
||||
<FormControlLabel
|
||||
className="checkbox-field"
|
||||
label={label}
|
||||
label={label ?? ''}
|
||||
control={
|
||||
<Checkbox
|
||||
{ ...args }
|
||||
{...args}
|
||||
className="checkbox-field__box"
|
||||
checked={!!value}
|
||||
onChange={(e, checked) => onChange(checked)}
|
||||
name={name}
|
||||
checked={Boolean(value)}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Select, MenuItem } from '@mui/material';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
|
|
@ -8,49 +7,48 @@ import { useLocaleSort } from '@app/hooks';
|
|||
import { Images } from '@app/images';
|
||||
import { App } from '@app/types';
|
||||
|
||||
import type { FinalFormFieldProps } from '../fieldTypes';
|
||||
|
||||
import './CountryDropdown.css';
|
||||
|
||||
const CountryDropdown = ({ input: { onChange } }) => {
|
||||
const [value, setValue] = useState('');
|
||||
type CountryDropdownProps = FinalFormFieldProps<string, HTMLElement>;
|
||||
|
||||
const CountryDropdown = ({ input }: CountryDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const currentValue = (input.value as string | undefined) ?? '';
|
||||
|
||||
useEffect(() => onChange(value), [value]);
|
||||
|
||||
const translateCountry = country => t(`Common.countries.${country}`);
|
||||
const translateCountry = (country: string) => t(`Common.countries.${country}`);
|
||||
const sortedCountries = useLocaleSort(App.countryCodes, translateCountry);
|
||||
|
||||
return (
|
||||
<FormControl size='small' variant='outlined' className='CountryDropdown'>
|
||||
<InputLabel id='CountryDropdown-select'>Country</InputLabel>
|
||||
<FormControl size="small" variant="outlined" className="CountryDropdown">
|
||||
<InputLabel id="CountryDropdown-label">Country</InputLabel>
|
||||
<Select
|
||||
id='CountryDropdown-select'
|
||||
labelId='CountryDropdown-label'
|
||||
label='Country'
|
||||
margin='dense'
|
||||
value={value}
|
||||
fullWidth={true}
|
||||
onChange={e => setValue(e.target.value as string)}
|
||||
id="CountryDropdown-select"
|
||||
labelId="CountryDropdown-label"
|
||||
label="Country"
|
||||
margin="dense"
|
||||
fullWidth
|
||||
{...input}
|
||||
value={currentValue}
|
||||
>
|
||||
<MenuItem value={''} key={-1}>
|
||||
<MenuItem value="" key="none">
|
||||
<div className="CountryDropdown-item">
|
||||
<span className="CountryDropdown-item__label">None</span>
|
||||
</div>
|
||||
</MenuItem>
|
||||
|
||||
{
|
||||
sortedCountries.map((country, index:number) => (
|
||||
<MenuItem value={country} key={index}>
|
||||
<div className="CountryDropdown-item">
|
||||
<img className="CountryDropdown-item__image" src={Images.Countries[country.toLowerCase()]} />
|
||||
<span className="CountryDropdown-item__label">{translateCountry(country)}</span>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))
|
||||
}
|
||||
{sortedCountries.map(country => (
|
||||
<MenuItem value={country} key={country}>
|
||||
<div className="CountryDropdown-item">
|
||||
<img className="CountryDropdown-item__image" src={Images.Countries[country.toLowerCase()]} />
|
||||
<span className="CountryDropdown-item__label">{translateCountry(country)}</span>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default CountryDropdown;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import type { Data } from '@app/types';
|
||||
import { cx } from '@app/utils';
|
||||
|
||||
|
|
@ -94,4 +96,4 @@ function CardSlot({
|
|||
);
|
||||
}
|
||||
|
||||
export default CardSlot;
|
||||
export default memo(CardSlot);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ function GameLog({ gameId }: GameLogProps) {
|
|||
const name = players?.[m.playerId]?.properties.userInfo?.name ?? `p${m.playerId}`;
|
||||
const lineClass = isEvent ? 'game-log__line game-log__line--event' : 'game-log__line';
|
||||
return (
|
||||
<div key={idx} className={lineClass}>
|
||||
<div key={`${m.timeReceived}-${idx}`} className={lineClass}>
|
||||
{!isEvent && <span className="game-log__author">{name}:</span>}
|
||||
<span className="game-log__text">{m.message}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ import { useMemo, useState } from 'react';
|
|||
import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks';
|
||||
import { GameSelectors, useAppSelector } from '@app/store';
|
||||
|
||||
/**
|
||||
* MTG turn phase count (0..10). Mirrors desktop's wrap-around behavior in
|
||||
* `GameView::actNextPhase` — see `types/game.ts` for the Phase enum.
|
||||
*/
|
||||
const PHASE_COUNT = 11;
|
||||
|
||||
export interface TurnControlsOpponent {
|
||||
playerId: number;
|
||||
name: string;
|
||||
|
|
@ -119,11 +125,11 @@ export function useTurnControls({
|
|||
if (!canAdvance || !hasLiveGame) {
|
||||
return;
|
||||
}
|
||||
// Desktop wraps at 11 → 0 (the Phase enum is 0–10). When no phase is
|
||||
// active yet (activePhase < 0 during the pre-game lobby), advance to
|
||||
// Desktop wraps at PHASE_COUNT → 0 (the Phase enum is 0–10). When no phase
|
||||
// is active yet (activePhase < 0 during the pre-game lobby), advance to
|
||||
// Untap (0).
|
||||
const current = game.activePhase;
|
||||
const next = current >= 0 ? (current + 1) % 11 : 0;
|
||||
const next = current >= 0 ? (current + 1) % PHASE_COUNT : 0;
|
||||
webClient.request.game.setActivePhase(gameId, { phase: next });
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { ServerSelectors, useAppSelector } from '@app/store';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { ServerSelectors, useAppSelector } from '@app/store';
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
.input-action__item {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.input-action__item > div {
|
||||
margin: 0;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,25 @@
|
|||
import React from 'react';
|
||||
import { Field } from 'react-final-form'
|
||||
import { Field } from 'react-final-form';
|
||||
import Button from '@mui/material/Button';
|
||||
|
||||
import { InputField } from '..';
|
||||
|
||||
import './InputAction.css';
|
||||
|
||||
const InputAction = ({ action, label, name, validate = () => false, disabled = false }) => (
|
||||
interface InputActionProps {
|
||||
action: string;
|
||||
label: string;
|
||||
name: string;
|
||||
validate?: (value: unknown) => string | undefined | false;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const InputAction = ({
|
||||
action,
|
||||
label,
|
||||
name,
|
||||
validate = () => undefined,
|
||||
disabled = false,
|
||||
}: InputActionProps) => (
|
||||
<div className="input-action">
|
||||
<div className="input-action__item">
|
||||
<Field label={label} name={name} component={InputField} validate={validate} />
|
||||
|
|
|
|||
|
|
@ -1,57 +1,57 @@
|
|||
import React from 'react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import TextField, { TextFieldProps } from '@mui/material/TextField';
|
||||
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
|
||||
|
||||
import type { FinalFormFieldProps } from '../fieldTypes';
|
||||
|
||||
import './InputField.css';
|
||||
|
||||
const PREFIX = 'InputField';
|
||||
|
||||
const classes = {
|
||||
root: `${PREFIX}-root`
|
||||
root: `${PREFIX}-root`,
|
||||
};
|
||||
|
||||
const Root = styled('div')(({ theme }) => ({
|
||||
[`&.${classes.root}`]: {
|
||||
'& .InputField-error': {
|
||||
color: theme.palette.error.main
|
||||
color: theme.palette.error.main,
|
||||
},
|
||||
|
||||
'& .InputField-warning': {
|
||||
color: theme.palette.warning.main
|
||||
color: theme.palette.warning.main,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const InputField = ({ input, meta, ...args }) => {
|
||||
type InputFieldProps =
|
||||
FinalFormFieldProps<string, HTMLInputElement> &
|
||||
Omit<TextFieldProps, 'value' | 'onChange' | 'onBlur' | 'onFocus' | 'name'>;
|
||||
|
||||
const InputField = ({ input, meta, ...args }: InputFieldProps) => {
|
||||
const { touched, error, warning } = meta;
|
||||
|
||||
return (
|
||||
<Root className={'InputField ' + classes.root}>
|
||||
{ touched && (
|
||||
<Root className={`InputField ${classes.root}`}>
|
||||
{touched && (
|
||||
<div className="InputField-validation">
|
||||
{
|
||||
(error &&
|
||||
<div className="InputField-error">
|
||||
{error}
|
||||
<ErrorOutlinedIcon style={{ fontSize: 'small', fontWeight: 'bold' }} />
|
||||
</div>
|
||||
) ||
|
||||
|
||||
(warning && <div className="InputField-warning">{warning}</div>)
|
||||
}
|
||||
{(error &&
|
||||
<div className="InputField-error">
|
||||
{error}
|
||||
<ErrorOutlinedIcon style={{ fontSize: 'small', fontWeight: 'bold' }} />
|
||||
</div>
|
||||
) || (warning && <div className="InputField-warning">{warning}</div>)}
|
||||
</div>
|
||||
) }
|
||||
)}
|
||||
|
||||
<TextField
|
||||
autoComplete='off'
|
||||
{ ...input }
|
||||
{ ...args }
|
||||
autoComplete="off"
|
||||
{...input}
|
||||
{...args}
|
||||
className="rounded"
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
size="small"
|
||||
fullWidth={true}
|
||||
fullWidth
|
||||
/>
|
||||
</Root>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { styled } from '@mui/material/styles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Select, MenuItem } from '@mui/material';
|
||||
import { Select, MenuItem, SelectChangeEvent } from '@mui/material';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
|
|
@ -14,8 +14,8 @@ import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
|
|||
|
||||
import { KnownHostDialog } from '@app/dialogs';
|
||||
import { getHostPort, HostDTO } from '@app/services';
|
||||
import Toast from '../Toast/Toast';
|
||||
|
||||
import type { FinalFormFieldProps } from '../fieldTypes';
|
||||
import { TestConnection, useKnownHostsComponent } from './useKnownHostsComponent';
|
||||
|
||||
import './KnownHosts.css';
|
||||
|
|
@ -50,9 +50,11 @@ const Root = styled('div')(({ theme }) => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const KnownHosts = (props: any) => {
|
||||
const { input, meta, disabled } = props;
|
||||
const onChange: (value: HostDTO) => void = input.onChange;
|
||||
type KnownHostsProps = FinalFormFieldProps<HostDTO | undefined, HTMLElement> & {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const KnownHosts = ({ input, meta, disabled }: KnownHostsProps) => {
|
||||
const { touched, error, warning } = meta;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -61,22 +63,25 @@ const KnownHosts = (props: any) => {
|
|||
selectedHost,
|
||||
testingConnection,
|
||||
dialogState,
|
||||
showCreateToast,
|
||||
showDeleteToast,
|
||||
showEditToast,
|
||||
setShowCreateToast,
|
||||
setShowDeleteToast,
|
||||
setShowEditToast,
|
||||
onPick,
|
||||
openAddKnownHostDialog,
|
||||
openEditKnownHostDialog,
|
||||
closeKnownHostDialog,
|
||||
handleDialogRemove,
|
||||
handleDialogSubmit,
|
||||
} = useKnownHostsComponent({ onChange });
|
||||
} = useKnownHostsComponent({ onChange: input.onChange });
|
||||
|
||||
const selectedId = selectedHost?.id ?? '';
|
||||
|
||||
const handleSelectChange = (event: SelectChangeEvent<number | ''>) => {
|
||||
const value = event.target.value;
|
||||
if (typeof value === 'number') {
|
||||
void onPick(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Root className={'KnownHosts ' + classes.root}>
|
||||
<Root className={`KnownHosts ${classes.root}`}>
|
||||
<FormControl className="KnownHosts-form" size="small" variant="outlined">
|
||||
{touched && (
|
||||
<div className="KnownHosts-validation">
|
||||
|
|
@ -97,24 +102,24 @@ const KnownHosts = (props: any) => {
|
|||
label="Host"
|
||||
margin="dense"
|
||||
name="host"
|
||||
value={selectedHost ?? ''}
|
||||
fullWidth={true}
|
||||
onChange={(e) => onPick(e.target.value as unknown as HostDTO)}
|
||||
value={selectedId}
|
||||
fullWidth
|
||||
onChange={handleSelectChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Button value={selectedHost} onClick={openAddKnownHostDialog}>
|
||||
<Button onClick={openAddKnownHostDialog}>
|
||||
<span>{t('KnownHosts.add')}</span>
|
||||
<AddIcon fontSize="small" color="primary" />
|
||||
</Button>
|
||||
|
||||
{hosts.map((host, index) => {
|
||||
{hosts.map((host) => {
|
||||
const hostPort = getHostPort(host);
|
||||
|
||||
return (
|
||||
<MenuItem value={host as any} key={host.id ?? index}>
|
||||
<MenuItem value={host.id} key={host.id}>
|
||||
<div className="KnownHosts-item">
|
||||
<div className="KnownHosts-item__wrapper">
|
||||
<div className={'KnownHosts-item__status ' + testingConnection}>
|
||||
<div className={`KnownHosts-item__status ${testingConnection ?? ''}`}>
|
||||
{testingConnection === TestConnection.FAILED ? (
|
||||
<PortableWifiOffIcon fontSize="small" />
|
||||
) : (
|
||||
|
|
@ -151,20 +156,11 @@ const KnownHosts = (props: any) => {
|
|||
|
||||
<KnownHostDialog
|
||||
isOpen={dialogState.open}
|
||||
host={dialogState.edit}
|
||||
host={dialogState.edit ?? undefined}
|
||||
onRemove={handleDialogRemove}
|
||||
onSubmit={handleDialogSubmit}
|
||||
handleClose={closeKnownHostDialog}
|
||||
/>
|
||||
<Toast open={showCreateToast} onClose={() => setShowCreateToast(false)}>
|
||||
{t('KnownHosts.toast', { mode: 'created' })}
|
||||
</Toast>
|
||||
<Toast open={showDeleteToast} onClose={() => setShowDeleteToast(false)}>
|
||||
{t('KnownHosts.toast', { mode: 'deleted' })}
|
||||
</Toast>
|
||||
<Toast open={showEditToast} onClose={() => setShowEditToast(false)}>
|
||||
{t('KnownHosts.toast', { mode: 'edited' })}
|
||||
</Toast>
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useToast } from '@app/components';
|
||||
import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
||||
import { getHostPort, HostDTO } from '@app/services';
|
||||
import { ServerTypes } from '@app/store';
|
||||
|
|
@ -16,13 +18,7 @@ export interface KnownHostsComponent {
|
|||
selectedHost: App.Host | undefined;
|
||||
testingConnection: TestConnection | null;
|
||||
dialogState: { open: boolean; edit: HostDTO | null };
|
||||
showCreateToast: boolean;
|
||||
showDeleteToast: boolean;
|
||||
showEditToast: boolean;
|
||||
setShowCreateToast: (v: boolean) => void;
|
||||
setShowDeleteToast: (v: boolean) => void;
|
||||
setShowEditToast: (v: boolean) => void;
|
||||
onPick: (host: HostDTO) => Promise<void>;
|
||||
onPick: (id: number) => Promise<void>;
|
||||
openAddKnownHostDialog: () => void;
|
||||
openEditKnownHostDialog: (host: HostDTO) => void;
|
||||
closeKnownHostDialog: () => void;
|
||||
|
|
@ -39,11 +35,20 @@ export interface UseKnownHostsComponentArgs {
|
|||
onChange: (value: HostDTO) => void;
|
||||
}
|
||||
|
||||
type ToastMode = 'created' | 'deleted' | 'edited';
|
||||
|
||||
export function useKnownHostsComponent({
|
||||
onChange,
|
||||
}: UseKnownHostsComponentArgs): KnownHostsComponent {
|
||||
const webClient = useWebClient();
|
||||
const knownHosts = useKnownHosts();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [toastMode, setToastMode] = useState<ToastMode>('created');
|
||||
const knownHostToast = useToast({
|
||||
key: 'known-hosts-action',
|
||||
children: t('KnownHosts.toast', { mode: toastMode }),
|
||||
});
|
||||
|
||||
const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({
|
||||
open: false,
|
||||
|
|
@ -51,16 +56,16 @@ export function useKnownHostsComponent({
|
|||
});
|
||||
|
||||
const [testingConnection, setTestingConnection] = useState<TestConnection | null>(null);
|
||||
|
||||
const [showCreateToast, setShowCreateToast] = useState(false);
|
||||
const [showDeleteToast, setShowDeleteToast] = useState(false);
|
||||
const [showEditToast, setShowEditToast] = useState(false);
|
||||
// Tracks the host currently awaiting a testConnection response. If null when a
|
||||
// response arrives, the caller has moved on — ignore the stale reply.
|
||||
const pendingTestRef = useRef<HostDTO | null>(null);
|
||||
|
||||
const selectedHost =
|
||||
knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined;
|
||||
const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : [];
|
||||
|
||||
const testConnection = (host: HostDTO) => {
|
||||
pendingTestRef.current = host;
|
||||
setTestingConnection(TestConnection.TESTING);
|
||||
webClient.request.authentication.testConnection({ ...getHostPort(host) });
|
||||
};
|
||||
|
|
@ -73,28 +78,37 @@ export function useKnownHostsComponent({
|
|||
testConnection(selectedHost);
|
||||
}, [selectedHost]);
|
||||
|
||||
useReduxEffect(
|
||||
() => {
|
||||
setTestingConnection(TestConnection.SUCCESS);
|
||||
},
|
||||
ServerTypes.TEST_CONNECTION_SUCCESSFUL,
|
||||
[],
|
||||
);
|
||||
useReduxEffect(() => {
|
||||
if (!pendingTestRef.current) {
|
||||
return;
|
||||
}
|
||||
setTestingConnection(TestConnection.SUCCESS);
|
||||
pendingTestRef.current = null;
|
||||
}, ServerTypes.TEST_CONNECTION_SUCCESSFUL, []);
|
||||
|
||||
useReduxEffect(
|
||||
() => {
|
||||
setTestingConnection(TestConnection.FAILED);
|
||||
},
|
||||
ServerTypes.TEST_CONNECTION_FAILED,
|
||||
[],
|
||||
);
|
||||
useReduxEffect(() => {
|
||||
if (!pendingTestRef.current) {
|
||||
return;
|
||||
}
|
||||
setTestingConnection(TestConnection.FAILED);
|
||||
pendingTestRef.current = null;
|
||||
}, ServerTypes.TEST_CONNECTION_FAILED, []);
|
||||
|
||||
const onPick = async (host: HostDTO) => {
|
||||
const fireToast = (mode: ToastMode) => {
|
||||
setToastMode(mode);
|
||||
knownHostToast.openToast();
|
||||
};
|
||||
|
||||
const onPick = async (id: number) => {
|
||||
if (knownHosts.status !== LoadingState.READY) {
|
||||
return;
|
||||
}
|
||||
const host = knownHosts.value?.hosts.find((h) => h.id === id);
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
onChange(host);
|
||||
await knownHosts.select(host.id!);
|
||||
await knownHosts.select(id);
|
||||
testConnection(host);
|
||||
};
|
||||
|
||||
|
|
@ -116,7 +130,7 @@ export function useKnownHostsComponent({
|
|||
}
|
||||
await knownHosts.remove(id);
|
||||
closeKnownHostDialog();
|
||||
setShowDeleteToast(true);
|
||||
fireToast('deleted');
|
||||
};
|
||||
|
||||
const handleDialogSubmit = async ({
|
||||
|
|
@ -136,11 +150,11 @@ export function useKnownHostsComponent({
|
|||
|
||||
if (id) {
|
||||
await knownHosts.update(id, { name, host, port });
|
||||
setShowEditToast(true);
|
||||
fireToast('edited');
|
||||
} else {
|
||||
const newHost: App.Host = { name, host, port, editable: true };
|
||||
await knownHosts.add(newHost);
|
||||
setShowCreateToast(true);
|
||||
fireToast('created');
|
||||
}
|
||||
|
||||
closeKnownHostDialog();
|
||||
|
|
@ -151,12 +165,6 @@ export function useKnownHostsComponent({
|
|||
selectedHost,
|
||||
testingConnection,
|
||||
dialogState,
|
||||
showCreateToast,
|
||||
showDeleteToast,
|
||||
showEditToast,
|
||||
setShowCreateToast,
|
||||
setShowDeleteToast,
|
||||
setShowEditToast,
|
||||
onPick,
|
||||
openAddKnownHostDialog,
|
||||
openEditKnownHostDialog,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
.LanguageDropdown {
|
||||
}
|
||||
|
||||
.LanguageDropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Select, MenuItem } from '@mui/material';
|
||||
import { Select, MenuItem, SelectChangeEvent } from '@mui/material';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
|
||||
import { Images } from '@app/images';
|
||||
|
|
@ -11,48 +9,43 @@ import './LanguageDropdown.css';
|
|||
|
||||
const LanguageDropdown = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
// i18next `resolvedLanguage` is undefined until a registered resource matches;
|
||||
// MUI Select requires a concrete, in-range value.
|
||||
const [language, setLanguage] = useState(i18n.resolvedLanguage ?? i18n.language ?? '');
|
||||
const currentLanguage = i18n.resolvedLanguage ?? i18n.language ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (language !== i18n.resolvedLanguage) {
|
||||
i18n.changeLanguage(language);
|
||||
const onLanguageChange = (event: SelectChangeEvent) => {
|
||||
const next = event.target.value as App.Language;
|
||||
if (next !== currentLanguage) {
|
||||
void i18n.changeLanguage(next);
|
||||
}
|
||||
}, [language]);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl size='small' variant='outlined' className='LanguageDropdown'>
|
||||
<FormControl size="small" variant="outlined" className="LanguageDropdown">
|
||||
<Select
|
||||
id='LanguageDropdown-select'
|
||||
margin='dense'
|
||||
value={language}
|
||||
fullWidth={true}
|
||||
onChange={e => setLanguage(e.target.value as App.Language)}
|
||||
id="LanguageDropdown-select"
|
||||
margin="dense"
|
||||
value={currentLanguage}
|
||||
fullWidth
|
||||
onChange={onLanguageChange}
|
||||
>
|
||||
{
|
||||
Object.keys(App.Language).map((lang) => {
|
||||
const country = App.LanguageCountry[lang];
|
||||
{Object.keys(App.Language).map((lang) => {
|
||||
const country = App.LanguageCountry[lang];
|
||||
const nativeName = App.LanguageNative[lang];
|
||||
const translatedName = t(`Common.languages.${lang}`);
|
||||
|
||||
return (
|
||||
<MenuItem value={lang} key={lang}>
|
||||
<div className="LanguageDropdown-item">
|
||||
<img className="LanguageDropdown-item__image" src={Images.Countries[country]} />
|
||||
<span className="LanguageDropdown-item__label">
|
||||
{App.LanguageNative[lang]} {
|
||||
App.LanguageNative[lang] !== t(`Common.languages.${lang}`) && (
|
||||
<>({ t(`Common.languages.${lang}`) })</>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
})
|
||||
}
|
||||
return (
|
||||
<MenuItem value={lang} key={lang}>
|
||||
<div className="LanguageDropdown-item">
|
||||
<img className="LanguageDropdown-item__image" src={Images.Countries[country]} />
|
||||
<span className="LanguageDropdown-item__label">
|
||||
{nativeName} {nativeName !== translatedName && <>({translatedName})</>}
|
||||
</span>
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageDropdown;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@ const Root = styled('span')(() => ({
|
|||
}
|
||||
}));
|
||||
|
||||
const CardCallout = ({ name }) => {
|
||||
interface CardCalloutProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const CardCallout = ({ name }: CardCalloutProps) => {
|
||||
const { card, token, anchorEl, open, handlePopoverOpen, handlePopoverClose } =
|
||||
useCardCallout(name);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,15 @@ import CardCallout from './CardCallout';
|
|||
import { useParsedMessage } from './useMessage';
|
||||
import './Message.css';
|
||||
|
||||
const Message = ({ message: { message } }) => (
|
||||
interface MessagePayload {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface MessageProps {
|
||||
message: MessagePayload;
|
||||
}
|
||||
|
||||
const Message = ({ message: { message } }: MessageProps) => (
|
||||
<div className='message'>
|
||||
<div className='message__detail'>
|
||||
<ParsedMessage message={message} />
|
||||
|
|
@ -15,7 +23,11 @@ const Message = ({ message: { message } }) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
const ParsedMessage = ({ message }) => {
|
||||
interface ParsedMessageProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const ParsedMessage = ({ message }: ParsedMessageProps) => {
|
||||
const { name, chunks } = useParsedMessage(message, parseChunks);
|
||||
|
||||
return (
|
||||
|
|
@ -26,7 +38,12 @@ const ParsedMessage = ({ message }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const PlayerLink = ({ name, label = name }) => (
|
||||
interface PlayerLinkProps {
|
||||
name: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const PlayerLink = ({ name, label = name }: PlayerLinkProps) => (
|
||||
<NavLink className="link" to={generatePath(App.RouteEnum.PLAYER, { name })}>
|
||||
{label}
|
||||
</NavLink>
|
||||
|
|
@ -69,7 +86,7 @@ function parseMentionChunk(chunk: string): ReactNode {
|
|||
|
||||
if (mention) {
|
||||
const name = mention[0].substr(1);
|
||||
return (<PlayerLink name={name} label={mention} key={index} />);
|
||||
return (<PlayerLink name={name} label={mention[0]} key={index} />);
|
||||
}
|
||||
|
||||
return mentionChunk;
|
||||
|
|
|
|||
|
|
@ -1,27 +1,25 @@
|
|||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
|
||||
import { App } from '@app/types';
|
||||
|
||||
export interface ParsedMessage {
|
||||
name: string | null;
|
||||
chunks: ReactNode[] | null;
|
||||
chunks: ReactNode[];
|
||||
}
|
||||
|
||||
export type ChunkParser = (chunk: string, index: number) => ReactNode;
|
||||
|
||||
// `parseChunk` must be a stable reference across renders (module-level function
|
||||
// or `useCallback`). Passing a fresh closure every render will thrash the memo.
|
||||
export function useParsedMessage(message: string, parseChunk: ChunkParser): ParsedMessage {
|
||||
const [chunks, setChunks] = useState<ReactNode[] | null>(null);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return useMemo<ParsedMessage>(() => {
|
||||
const match = message.match(App.MESSAGE_SENDER_REGEX);
|
||||
if (match) {
|
||||
setName(match[1]);
|
||||
}
|
||||
setChunks(parseMessage(message, parseChunk));
|
||||
const name = match ? match[1] : null;
|
||||
return {
|
||||
name,
|
||||
chunks: parseMessage(message, parseChunk),
|
||||
};
|
||||
}, [message, parseChunk]);
|
||||
|
||||
return { name, chunks };
|
||||
}
|
||||
|
||||
export function parseMessage(message: string, parseChunk: ChunkParser): ReactNode[] {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,23 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { ReactNode, useEffect, useRef } from 'react';
|
||||
|
||||
const ScrollToBottomOnChanges = ({ content, changes }) => {
|
||||
const messagesEndRef = useRef(null);
|
||||
interface ScrollToBottomOnChangesProps {
|
||||
content: ReactNode;
|
||||
changes: unknown;
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
const ScrollToBottomOnChanges = ({ content, changes }: ScrollToBottomOnChangesProps) => {
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(scrollToBottom, [changes]);
|
||||
|
||||
const styling = {
|
||||
height: '100%'
|
||||
};
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [changes]);
|
||||
|
||||
return (
|
||||
<div style={styling}>
|
||||
<div style={{ height: '100%' }}>
|
||||
{content}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default ScrollToBottomOnChanges;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,29 @@
|
|||
import React from 'react';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
|
||||
import type { FinalFormFieldProps } from '../fieldTypes';
|
||||
|
||||
import './SelectField.css';
|
||||
|
||||
const SelectField = ({ input, label, options, value }) => {
|
||||
const id = label + '-select-field';
|
||||
const labelId = id + '-label';
|
||||
export interface SelectFieldOption<V extends string | number = string | number> {
|
||||
value: V;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectFieldProps<V extends string | number = string | number> extends FinalFormFieldProps<V, HTMLElement> {
|
||||
label: string;
|
||||
options: SelectFieldOption<V>[];
|
||||
}
|
||||
|
||||
const SelectField = <V extends string | number = string | number>({
|
||||
input,
|
||||
label,
|
||||
options,
|
||||
}: SelectFieldProps<V>) => {
|
||||
const id = `${label}-select-field`;
|
||||
const labelId = `${id}-label`;
|
||||
|
||||
return (
|
||||
<FormControl variant="outlined" margin="dense" className="select-field">
|
||||
|
|
@ -16,13 +31,15 @@ const SelectField = ({ input, label, options, value }) => {
|
|||
<Select
|
||||
labelId={labelId}
|
||||
id={id}
|
||||
value={value}
|
||||
{ ...input }
|
||||
>{
|
||||
options.map((option, index) => (
|
||||
<MenuItem value={index} key={index}> { option } </MenuItem>
|
||||
))
|
||||
}</Select>
|
||||
label={label}
|
||||
{...input}
|
||||
>
|
||||
{options.map(option => (
|
||||
<MenuItem value={option.value} key={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
.three-pane-layout .grid-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.three-pane-layout .grid-main__top {
|
||||
|
|
|
|||
|
|
@ -1,35 +1,34 @@
|
|||
import * as React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ReactNode, SyntheticEvent } from 'react';
|
||||
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Alert, { AlertColor } from '@mui/material/Alert';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import Slide from '@mui/material/Slide';
|
||||
import Slide, { SlideProps } from '@mui/material/Slide';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
|
||||
const iconMapping = {
|
||||
success: <CheckCircleIcon />
|
||||
success: <CheckCircleIcon />,
|
||||
};
|
||||
|
||||
export interface ToastProps {
|
||||
open: boolean;
|
||||
onClose: (event?: SyntheticEvent) => void;
|
||||
severity?: AlertColor;
|
||||
autoHideDuration?: number;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function Toast(props) {
|
||||
const { open, onClose, severity = 'success', autoHideDuration = 10000, children } = props
|
||||
|
||||
const rootElemRef = React.useRef(document.createElement('div'));
|
||||
|
||||
React.useEffect(() => {
|
||||
document.body.appendChild(rootElemRef.current)
|
||||
return () => {
|
||||
rootElemRef.current.remove();
|
||||
}
|
||||
}, [rootElemRef])
|
||||
|
||||
const handleClose = (event?: React.SyntheticEvent, reason?: string) => {
|
||||
// MUI's Snackbar already self-portals to the end of document.body; adding our
|
||||
// own createPortal wrapper would leak <div>s under React StrictMode's double-
|
||||
// invoked effects. Render the Snackbar directly.
|
||||
function Toast({ open, onClose, severity = 'success', autoHideDuration = 10000, children }: ToastProps) {
|
||||
const handleClose = (event?: SyntheticEvent | Event, reason?: string) => {
|
||||
if (reason === 'clickaway') {
|
||||
return;
|
||||
}
|
||||
onClose(event);
|
||||
onClose(event as SyntheticEvent | undefined);
|
||||
};
|
||||
|
||||
const node = (
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={autoHideDuration}
|
||||
|
|
@ -37,23 +36,18 @@ function Toast(props) {
|
|||
slots={{ transition: TransitionLeft }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<Alert onClose={handleClose} severity={severity} iconMapping={iconMapping}
|
||||
<Alert
|
||||
onClose={handleClose}
|
||||
severity={severity}
|
||||
iconMapping={iconMapping}
|
||||
slotProps={{ message: { children } }}
|
||||
/>
|
||||
</Snackbar>
|
||||
)
|
||||
if (!rootElemRef.current) {
|
||||
return null
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
node,
|
||||
rootElemRef.current
|
||||
);
|
||||
}
|
||||
|
||||
function TransitionLeft(props) {
|
||||
function TransitionLeft(props: SlideProps) {
|
||||
return <Slide {...props} direction="left" />;
|
||||
}
|
||||
|
||||
export default Toast
|
||||
export default Toast;
|
||||
|
|
|
|||
|
|
@ -1,71 +1,77 @@
|
|||
import { createContext, FC, PropsWithChildren, ReactChild, ReactNode, useContext, useEffect, useReducer, Context } from 'react'
|
||||
import { createContext, FC, PropsWithChildren, ReactNode, useContext, useEffect, useReducer } from 'react';
|
||||
|
||||
import { ACTIONS, initialState, reducer } from './reducer';
|
||||
import Toast from './Toast'
|
||||
import { ACTIONS, initialState, reducer, ToastEntry } from './reducer';
|
||||
import Toast from './Toast';
|
||||
|
||||
interface ToastEntry {
|
||||
isOpen: boolean,
|
||||
children: ReactChild,
|
||||
interface ToastContextValue {
|
||||
toasts: Record<string, ToastEntry>;
|
||||
addToast: (key: string, children: ReactNode) => void;
|
||||
openToast: (key: string) => void;
|
||||
closeToast: (key: string) => void;
|
||||
removeToast: (key: string) => void;
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
toasts: Record<string, ToastEntry>,
|
||||
addToast: (key, children) => void,
|
||||
openToast: (key) => void,
|
||||
closeToast: (key) => void,
|
||||
removeToast: (key) => void,
|
||||
}
|
||||
|
||||
const ToastContext: Context<any> = createContext<ToastState>({
|
||||
const ToastContext = createContext<ToastContextValue>({
|
||||
toasts: {},
|
||||
addToast: (_key, _children) => {},
|
||||
openToast: (_key) => {},
|
||||
closeToast: (_key) => {},
|
||||
removeToast: (_key) => {},
|
||||
addToast: () => {},
|
||||
openToast: () => {},
|
||||
closeToast: () => {},
|
||||
removeToast: () => {},
|
||||
});
|
||||
|
||||
export const ToastProvider: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props
|
||||
const [state, dispatch] = useReducer(reducer, initialState)
|
||||
const providerState = {
|
||||
export const ToastProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const providerState: ToastContextValue = {
|
||||
toasts: state.toasts,
|
||||
addToast: (key, children) => dispatch({ type: ACTIONS.ADD_TOAST, payload: { key, children } }),
|
||||
openToast: key => dispatch({ type: ACTIONS.OPEN_TOAST, payload: { key } }),
|
||||
closeToast: key => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } }),
|
||||
removeToast: key => dispatch({ type: ACTIONS.REMOVE_TOAST, payload: { key } }),
|
||||
}
|
||||
addToast: (key, toastChildren) => dispatch({ type: ACTIONS.ADD_TOAST, payload: { key, children: toastChildren } }),
|
||||
openToast: (key) => dispatch({ type: ACTIONS.OPEN_TOAST, payload: { key } }),
|
||||
closeToast: (key) => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } }),
|
||||
removeToast: (key) => dispatch({ type: ACTIONS.REMOVE_TOAST, payload: { key } }),
|
||||
};
|
||||
return (
|
||||
<ToastContext.Provider value={providerState}>
|
||||
{children}
|
||||
<div>
|
||||
{Object.entries(state.toasts).map(([key, value]: [string, ToastEntry]) => {
|
||||
const { isOpen, children } = value;
|
||||
return (
|
||||
<Toast key={key} open={isOpen} onClose={() => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } })}>
|
||||
{children}
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
{Object.entries(state.toasts).map(([key, entry]) => (
|
||||
<Toast
|
||||
key={key}
|
||||
open={entry.isOpen}
|
||||
onClose={() => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: { key } })}
|
||||
>
|
||||
{entry.children}
|
||||
</Toast>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export interface ToastHookOptions {
|
||||
key: string,
|
||||
children: ReactNode
|
||||
key: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function useToast({ key, children }) {
|
||||
const { addToast, openToast, closeToast, removeToast } = useContext(ToastContext)
|
||||
export interface ToastHandle {
|
||||
openToast: () => void;
|
||||
closeToast: () => void;
|
||||
removeToast: () => void;
|
||||
}
|
||||
|
||||
export function useToast({ key, children }: ToastHookOptions): ToastHandle {
|
||||
const { addToast, openToast, closeToast, removeToast } = useContext(ToastContext);
|
||||
|
||||
// Toast children are captured at registration; re-registering every render
|
||||
// would churn provider state. Intentional mount/unmount-only effect keyed on `key`.
|
||||
useEffect(() => {
|
||||
addToast(key, children)
|
||||
}, [])
|
||||
addToast(key, children);
|
||||
return () => {
|
||||
removeToast(key);
|
||||
};
|
||||
}, [key]);
|
||||
|
||||
return {
|
||||
openToast: () => openToast(key),
|
||||
closeToast: () => closeToast(key),
|
||||
removeToast: () => removeToast(key),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,88 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
export const ACTIONS = {
|
||||
ADD_TOAST: 'ADD_TOAST',
|
||||
OPEN_TOAST: 'OPEN_TOAST',
|
||||
CLOSE_TOAST: 'CLOSE_TOAST',
|
||||
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||
} as const;
|
||||
|
||||
export interface ToastEntry {
|
||||
isOpen: boolean;
|
||||
children: ReactNode;
|
||||
// Refcount of active registrants for this key. Incremented on ADD, decremented on REMOVE.
|
||||
// Prevents two mounted callers sharing a key from stomping each other's registration.
|
||||
refs: number;
|
||||
}
|
||||
|
||||
export const initialState = {
|
||||
toasts: {}
|
||||
export interface ToastState {
|
||||
toasts: Record<string, ToastEntry>;
|
||||
}
|
||||
|
||||
export function reducer(state, { type, payload }) {
|
||||
const { key, children } = payload;
|
||||
export const initialState: ToastState = {
|
||||
toasts: {},
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
export type ToastAction =
|
||||
| { type: typeof ACTIONS.ADD_TOAST; payload: { key: string; children: ReactNode } }
|
||||
| { type: typeof ACTIONS.OPEN_TOAST; payload: { key: string } }
|
||||
| { type: typeof ACTIONS.CLOSE_TOAST; payload: { key: string } }
|
||||
| { type: typeof ACTIONS.REMOVE_TOAST; payload: { key: string } };
|
||||
|
||||
export function reducer(state: ToastState, action: ToastAction): ToastState {
|
||||
switch (action.type) {
|
||||
case ACTIONS.ADD_TOAST: {
|
||||
const { key, children } = action.payload;
|
||||
const existing = state.toasts[key];
|
||||
return {
|
||||
...state,
|
||||
toasts: {
|
||||
...state.toasts,
|
||||
[key]: {
|
||||
isOpen: false,
|
||||
children,
|
||||
},
|
||||
[key]: existing
|
||||
? { ...existing, refs: existing.refs + 1 }
|
||||
: { isOpen: false, children, refs: 1 },
|
||||
},
|
||||
};
|
||||
}
|
||||
case ACTIONS.OPEN_TOAST: {
|
||||
const { key } = action.payload;
|
||||
const existing = state.toasts[key];
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: {
|
||||
...state.toasts,
|
||||
[key]: {
|
||||
...state.toasts[key],
|
||||
isOpen: true,
|
||||
},
|
||||
},
|
||||
toasts: { ...state.toasts, [key]: { ...existing, isOpen: true } },
|
||||
};
|
||||
}
|
||||
case ACTIONS.CLOSE_TOAST: {
|
||||
const { key } = action.payload;
|
||||
const existing = state.toasts[key];
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: {
|
||||
...state.toasts,
|
||||
[key]: {
|
||||
...state.toasts[key],
|
||||
isOpen: false,
|
||||
},
|
||||
},
|
||||
toasts: { ...state.toasts, [key]: { ...existing, isOpen: false } },
|
||||
};
|
||||
}
|
||||
case ACTIONS.REMOVE_TOAST: {
|
||||
const newState = { ...state };
|
||||
delete newState.toasts[key];
|
||||
|
||||
return newState;
|
||||
const { key } = action.payload;
|
||||
const existing = state.toasts[key];
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
if (existing.refs > 1) {
|
||||
return {
|
||||
...state,
|
||||
toasts: { ...state.toasts, [key]: { ...existing, refs: existing.refs - 1 } },
|
||||
};
|
||||
}
|
||||
const nextToasts = { ...state.toasts };
|
||||
delete nextToasts[key];
|
||||
return { ...state, toasts: nextToasts };
|
||||
}
|
||||
default:
|
||||
throw Error('Please pick an available action')
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
// eslint-disable-next-line
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { TokenDTO } from '@app/services';
|
||||
|
||||
import './Token.css';
|
||||
|
|
@ -10,10 +7,11 @@ interface TokenProps {
|
|||
}
|
||||
|
||||
const Token = ({ token }: TokenProps) => {
|
||||
const set = Array.isArray(token?.set) ? token?.set[0] : token?.set;
|
||||
return token && (
|
||||
<img className="token" src={set?.picURL} alt={token?.name?.value} />
|
||||
);
|
||||
}
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const set = Array.isArray(token.set) ? token.set[0] : token.set;
|
||||
return <img className="token" src={set?.picURL} alt={token.name?.value} />;
|
||||
};
|
||||
|
||||
export default Token;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
// eslint-disable-next-line
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { TokenDTO } from '@app/services';
|
||||
|
||||
import Token from '../Token/Token';
|
||||
|
|
@ -21,7 +18,7 @@ const TokenDetails = ({ token }: TokenProps) => {
|
|||
</div>
|
||||
|
||||
{
|
||||
token && (
|
||||
token && props && (
|
||||
<div>
|
||||
<div className='tokenDetails-attributes'>
|
||||
<div className='tokenDetails-attribute'>
|
||||
|
|
@ -29,52 +26,42 @@ const TokenDetails = ({ token }: TokenProps) => {
|
|||
<span className='tokenDetails-attribute__value'>{token.name?.value}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
(!props.pt?.value) ? null : (
|
||||
<div className='tokenDetails-attribute'>
|
||||
<span className='tokenDetails-attribute__label'>P/T:</span>
|
||||
<span className='tokenDetails-attribute__value'>{props.pt.value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{props.pt?.value && (
|
||||
<div className='tokenDetails-attribute'>
|
||||
<span className='tokenDetails-attribute__label'>P/T:</span>
|
||||
<span className='tokenDetails-attribute__value'>{props.pt.value}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{
|
||||
!props.colors?.value ? null : (
|
||||
<div className='cardDetails-attribute'>
|
||||
<span className='cardDetails-attribute__label'>Color(s):</span>
|
||||
<span className='cardDetails-attribute__value'>{props.colors.value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{props.colors?.value && (
|
||||
<div className='tokenDetails-attribute'>
|
||||
<span className='tokenDetails-attribute__label'>Color(s):</span>
|
||||
<span className='tokenDetails-attribute__value'>{props.colors.value}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{
|
||||
!props.maintype?.value ? null : (
|
||||
<div className='cardDetails-attribute'>
|
||||
<span className='cardDetails-attribute__label'>Main Type:</span>
|
||||
<span className='cardDetails-attribute__value'>{props.maintype.value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{props.maintype?.value && (
|
||||
<div className='tokenDetails-attribute'>
|
||||
<span className='tokenDetails-attribute__label'>Main Type:</span>
|
||||
<span className='tokenDetails-attribute__value'>{props.maintype.value}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{
|
||||
!props.type?.value ? null : (
|
||||
<div className='cardDetails-attribute'>
|
||||
<span className='cardDetails-attribute__label'>Type:</span>
|
||||
<span className='cardDetails-attribute__value'>{props.type.value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{props.type?.value && (
|
||||
<div className='tokenDetails-attribute'>
|
||||
<span className='tokenDetails-attribute__label'>Type:</span>
|
||||
<span className='tokenDetails-attribute__value'>{props.type.value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{
|
||||
!token.text?.value ? null : (
|
||||
<div className='tokenDetails-text'>
|
||||
<div className='tokenDetails-text__current'>
|
||||
{token.text.value}
|
||||
</div>
|
||||
{token.text?.value && (
|
||||
<div className='tokenDetails-text'>
|
||||
<div className='tokenDetails-text__current'>
|
||||
{token.text.value}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const UserDisplay = ({ user }: UserDisplayProps) => {
|
|||
<div className="user-display">
|
||||
<NavLink to={generatePath(App.RouteEnum.PLAYER, { name })} className="plain-link">
|
||||
<div className="user-display__details" onContextMenu={handleClick}>
|
||||
<img className="user-display__country" src={Images.Countries[country]} alt={country}></img>
|
||||
<img className="user-display__country" src={Images.Countries[country]} alt={country} />
|
||||
<div className="user-display__name single-line-ellipsis">{name}</div>
|
||||
</div>
|
||||
</NavLink>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
// eslint-disable-next-line
|
||||
import React from "react";
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { List, RowComponentProps } from 'react-window';
|
||||
|
||||
import './VirtualList.css';
|
||||
|
||||
interface RowData {
|
||||
items: any[];
|
||||
items: ReactNode[];
|
||||
}
|
||||
|
||||
interface VirtualListProps {
|
||||
items: ReactNode[];
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const Row = ({ index, style, items }: RowComponentProps<RowData>) => (
|
||||
|
|
@ -15,7 +19,7 @@ const Row = ({ index, style, items }: RowComponentProps<RowData>) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
const VirtualList = ({ items, className = '', size = 30 }) => (
|
||||
const VirtualList = ({ items, className = '', size = 30 }: VirtualListProps) => (
|
||||
<div className="virtual-list">
|
||||
<List<RowData>
|
||||
className={`virtual-list__list ${className}`}
|
||||
|
|
|
|||
3
webclient/src/components/fieldTypes.ts
Normal file
3
webclient/src/components/fieldTypes.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import type { FieldRenderProps } from 'react-final-form';
|
||||
|
||||
export type FinalFormFieldProps<T, E extends HTMLElement = HTMLElement> = FieldRenderProps<T, E>;
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
export type { FinalFormFieldProps } from './fieldTypes';
|
||||
|
||||
// Common components
|
||||
export { default as Card } from './Card/Card';
|
||||
export { default as CardDetails } from './CardDetails/CardDetails';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue