Comprehensive review changes

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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