use component hooks

This commit is contained in:
seavor 2026-04-20 07:38:28 -05:00
parent 515dff6d7b
commit 3aa8c654cc
81 changed files with 5203 additions and 3173 deletions

View file

@ -0,0 +1,53 @@
import { ReactNode } from 'react';
import { Provider } from 'react-redux';
import { configureStore, Reducer } from '@reduxjs/toolkit';
import { WebClientContext } from '../hooks/useWebClient';
import type { WebClient } from '../websocket';
import { createMockWebClient } from './mockWebClient';
// Minimal Provider wrapper for hook-only tests. Use this instead of
// `renderWithProviders` when you need `renderHook` — the full provider tree
// auto-instantiates the singleton store via `@app/store`, which races with
// any test-local store you preload. Deep-import the reducer(s) you need and
// pass them here (see useCurrentGame.spec.tsx for the canonical pattern).
export function makeReduxHookWrapper<S>(
reducer: Reducer<S>,
preloadedState: S,
) {
const store = configureStore({
reducer,
preloadedState: preloadedState as Parameters<typeof configureStore>[0]['preloadedState'],
});
function Wrapper({ children }: { children: ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}
return { Wrapper, store };
}
export interface MakeReduxWebClientHookWrapperOptions<S> {
reducer: Reducer<S>;
preloadedState: S;
webClient?: WebClient;
}
export function makeReduxWebClientHookWrapper<S>({
reducer,
preloadedState,
webClient,
}: MakeReduxWebClientHookWrapperOptions<S>) {
const store = configureStore({
reducer,
preloadedState: preloadedState as Parameters<typeof configureStore>[0]['preloadedState'],
});
const client = webClient ?? createMockWebClient();
function Wrapper({ children }: { children: ReactNode }) {
return (
<Provider store={store}>
<WebClientContext value={client}>{children}</WebClientContext>
</Provider>
);
}
return { Wrapper, store, webClient: client };
}

View file

@ -1,6 +1,3 @@
// eslint-disable-next-line
import React, { useMemo, useState } from 'react';
import { CardDTO } from '@app/services';
import Card from '../Card/Card';

View file

@ -1,11 +1,9 @@
import { useMemo } from 'react';
import { App, Data } from '@app/types';
import { GameSelectors, useAppSelector } from '@app/store';
import { useSettings } from '@app/hooks';
import CardSlot from '../CardSlot/CardSlot';
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
import BattlefieldRow from './BattlefieldRow';
import { useBattlefield } from './useBattlefield';
import './Battlefield.css';
@ -21,13 +19,6 @@ export interface BattlefieldProps {
onCardDoubleClick?: (card: Data.ServerInfo_Card) => void;
}
const ROW_COUNT = 3;
function rowIndexFor(card: Data.ServerInfo_Card): number {
const y = card.y ?? 0;
return Math.max(0, Math.min(ROW_COUNT - 1, y));
}
function Battlefield({
gameId,
playerId,
@ -39,28 +30,7 @@ function Battlefield({
onCardContextMenu,
onCardDoubleClick,
}: BattlefieldProps) {
const cards = useAppSelector((state) =>
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.TABLE),
);
const { value: settings } = useSettings();
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
// Mirrors desktop TableZone::isInverted() — XOR of per-player mirrored and
// the global invertVerticalCoordinate preference.
const isInverted = mirrored !== invertVerticalCoordinate;
const rows = useMemo<Data.ServerInfo_Card[][]>(() => {
const bucketed: Data.ServerInfo_Card[][] = Array.from({ length: ROW_COUNT }, () => []);
for (const card of cards) {
bucketed[rowIndexFor(card)].push(card);
}
for (const row of bucketed) {
row.sort((a, b) => (a.x ?? 0) - (b.x ?? 0));
}
return bucketed;
}, [cards]);
const rowOrder = isInverted ? [2, 1, 0] : [0, 1, 2];
const { rows, rowOrder, isInverted } = useBattlefield({ gameId, playerId, mirrored });
return (
<div className="battlefield" data-testid="battlefield">

View file

@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { App, Data } from '@app/types';
import { GameSelectors, useAppSelector } from '@app/store';
import { useSettings } from '@app/hooks';
export interface Battlefield {
rows: Data.ServerInfo_Card[][];
rowOrder: number[];
isInverted: boolean;
}
export interface UseBattlefieldArgs {
gameId: number;
playerId: number;
mirrored: boolean;
}
const ROW_COUNT = 3;
function rowIndexFor(card: Data.ServerInfo_Card): number {
const y = card.y ?? 0;
return Math.max(0, Math.min(ROW_COUNT - 1, y));
}
export function useBattlefield({ gameId, playerId, mirrored }: UseBattlefieldArgs): Battlefield {
const cards = useAppSelector((state) =>
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.TABLE),
);
const { value: settings } = useSettings();
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
// Mirrors desktop TableZone::isInverted() — XOR of per-player mirrored and
// the global invertVerticalCoordinate preference.
const isInverted = mirrored !== invertVerticalCoordinate;
const rows = useMemo<Data.ServerInfo_Card[][]>(() => {
const bucketed: Data.ServerInfo_Card[][] = Array.from({ length: ROW_COUNT }, () => []);
for (const card of cards) {
bucketed[rowIndexFor(card)].push(card);
}
for (const row of bucketed) {
row.sort((a, b) => (a.x ?? 0) - (b.x ?? 0));
}
return bucketed;
}, [cards]);
const rowOrder = isInverted ? [2, 1, 0] : [0, 1, 2];
return { rows, rowOrder, isInverted };
}

View file

@ -2,8 +2,9 @@ import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import { useWebClient } from '@app/hooks';
import { App, Data } from '@app/types';
import { Data } from '@app/types';
import { useCardContextMenu } from './useCardContextMenu';
import './CardContextMenu.css';
@ -24,156 +25,33 @@ export interface CardContextMenuProps {
onRequestMoveToLibraryAt: () => void;
}
interface MoveTarget {
label: string;
zone: string;
x: number;
y: number;
}
function CardContextMenu(props: CardContextMenuProps) {
const { isOpen, anchorPosition, card, onClose } = props;
const {
ready,
isOwnedByLocal,
canAttach,
isAttached,
moveTargets,
handleFlip,
handleTapToggle,
handleFaceDownToggle,
handleDoesntUntapToggle,
handleSetPT,
handleSetAnnotation,
handleCardCounterDelta,
handleSetCardCounter,
handleDrawArrow,
handleAttach,
handleUnattach,
handleMove,
handleMoveToLibraryAt,
} = useCardContextMenu(props);
// Mirrors desktop's cockatrice/src/game/player/menu/move_menu.cpp:32-42 —
// six fixed targets plus one prompt ("Move to library at position…") for the
// 7-entry parity. Note that desktop's "Send to Table" label maps to our
// "Send to Battlefield" (same wire semantics: zone=table, x=0, y=0); the
// label diverges but the command is identical.
const MOVE_TARGETS: ReadonlyArray<MoveTarget> = [
{ label: 'Send to Hand', zone: App.ZoneName.HAND, x: -1, y: 0 },
{ label: 'Send to Battlefield', zone: App.ZoneName.TABLE, x: 0, y: 0 },
{ label: 'Send to Graveyard', zone: App.ZoneName.GRAVE, x: 0, y: 0 },
{ label: 'Send to Exile', zone: App.ZoneName.EXILE, x: 0, y: 0 },
{ label: 'Send to Library (top)', zone: App.ZoneName.DECK, x: 0, y: 0 },
{ label: 'Send to Library (bottom)', zone: App.ZoneName.DECK, x: -1, y: 0 },
];
function CardContextMenu({
isOpen,
anchorPosition,
gameId,
localPlayerId,
card,
ownerPlayerId,
sourceZone,
onClose,
onRequestSetPT,
onRequestSetAnnotation,
onRequestSetCounter,
onRequestDrawArrow,
onRequestAttach,
onRequestMoveToLibraryAt,
}: CardContextMenuProps) {
const webClient = useWebClient();
if (!card || ownerPlayerId == null || sourceZone == null || localPlayerId == null) {
if (!ready || !card) {
return null;
}
const game = webClient.request.game;
const zone = sourceZone;
const cardId = card.id;
const setAttr = (attribute: Data.CardAttribute, value: string) => {
game.setCardAttr(gameId, { zone, cardId, attribute, attrValue: value });
};
const handleFlip = () => {
// TODO(card-db): desktop's Player::actCardMenuFlip reads the card's stored
// P/T and forwards it so the revealed side shows the correct stats
// (cockatrice/src/game/player/player_actions.cpp:1805-1810). We can't
// do that without a card-database-by-name lookup, which isn't wired in
// the webclient yet. The server re-derives PT from the card DB for known
// names, so omitting `pt` is harmless for non-custom cards.
game.flipCard(gameId, { zone, cardId, faceDown: !card.faceDown });
onClose();
};
const handleTapToggle = () => {
setAttr(Data.CardAttribute.AttrTapped, card.tapped ? '0' : '1');
onClose();
};
const handleFaceDownToggle = () => {
setAttr(Data.CardAttribute.AttrFaceDown, card.faceDown ? '0' : '1');
onClose();
};
const handleDoesntUntapToggle = () => {
setAttr(Data.CardAttribute.AttrDoesntUntap, card.doesntUntap ? '0' : '1');
onClose();
};
const handleSetPT = () => {
onRequestSetPT();
onClose();
};
const handleSetAnnotation = () => {
onRequestSetAnnotation();
onClose();
};
const handleCardCounterDelta = (delta: number) => {
game.incCardCounter(gameId, {
zone,
cardId,
counterId: 0,
counterDelta: delta,
});
onClose();
};
const handleSetCardCounter = () => {
onRequestSetCounter();
onClose();
};
const handleDrawArrow = () => {
onRequestDrawArrow();
onClose();
};
const handleAttach = () => {
onRequestAttach();
onClose();
};
const handleUnattach = () => {
// Desktop's actUnattach sends only start_zone + card_id; the server uses
// proto2 presence (`has_target_player_id()`) to detect "detach". Setting
// targetPlayerId: -1 here would leave presence set and trip the attach
// code path server-side. MessageInitShape makes these fields optional,
// so omitting them produces an unset wire field.
game.attachCard(gameId, { startZone: zone, cardId });
onClose();
};
const isAttached = card.attachCardId >= 0;
// Desktop's actAttach is only available from a table card; other zones
// never expose the attach arrow.
const canAttach = sourceZone === App.ZoneName.TABLE;
// Mutating actions (tap, flip, counters, attrs, P/T, annotation, attach,
// move) require ownership of the card — matches desktop's
// `card_menu.cpp:151-161` which drops all mutators when the menu target
// isn't getLocalOrJudge()-modifiable. Read-only actions (Draw arrow)
// stay available for planning/communication.
const isOwnedByLocal = ownerPlayerId === localPlayerId;
const handleMove = (target: MoveTarget) => {
// targetPlayerId is the ACTING player (local), matching desktop's
// Player::actMoveCardTo* which uses playerInfo->getId().
game.moveCard(gameId, {
startPlayerId: ownerPlayerId,
startZone: sourceZone,
cardsToMove: { card: [{ cardId }] },
targetPlayerId: localPlayerId,
targetZone: target.zone,
x: target.x,
y: target.y,
isReversed: false,
});
onClose();
};
return (
<Menu
open={isOpen}
@ -212,17 +90,12 @@ function CardContextMenu({
{isOwnedByLocal && (
<>
<Divider />
{MOVE_TARGETS.map((t) => (
{moveTargets.map((t) => (
<MenuItem key={t.label} onClick={() => handleMove(t)}>
{t.label}
</MenuItem>
))}
<MenuItem
onClick={() => {
onRequestMoveToLibraryAt();
onClose();
}}
>
<MenuItem onClick={handleMoveToLibraryAt}>
Move to library at position
</MenuItem>
</>

View file

@ -0,0 +1,239 @@
import { useWebClient } from '@app/hooks';
import { App, Data } from '@app/types';
interface MoveTarget {
label: string;
zone: string;
x: number;
y: number;
}
// Mirrors desktop's cockatrice/src/game/player/menu/move_menu.cpp:32-42 —
// six fixed targets plus one prompt ("Move to library at position…") for the
// 7-entry parity. Note that desktop's "Send to Table" label maps to our
// "Send to Battlefield" (same wire semantics: zone=table, x=0, y=0); the
// label diverges but the command is identical.
export const CARD_MOVE_TARGETS: ReadonlyArray<MoveTarget> = [
{ label: 'Send to Hand', zone: App.ZoneName.HAND, x: -1, y: 0 },
{ label: 'Send to Battlefield', zone: App.ZoneName.TABLE, x: 0, y: 0 },
{ label: 'Send to Graveyard', zone: App.ZoneName.GRAVE, x: 0, y: 0 },
{ label: 'Send to Exile', zone: App.ZoneName.EXILE, x: 0, y: 0 },
{ label: 'Send to Library (top)', zone: App.ZoneName.DECK, x: 0, y: 0 },
{ label: 'Send to Library (bottom)', zone: App.ZoneName.DECK, x: -1, y: 0 },
];
export interface CardContextMenu {
ready: boolean;
isOwnedByLocal: boolean;
canAttach: boolean;
isAttached: boolean;
moveTargets: ReadonlyArray<MoveTarget>;
handleFlip: () => void;
handleTapToggle: () => void;
handleFaceDownToggle: () => void;
handleDoesntUntapToggle: () => void;
handleSetPT: () => void;
handleSetAnnotation: () => void;
handleCardCounterDelta: (delta: number) => void;
handleSetCardCounter: () => void;
handleDrawArrow: () => void;
handleAttach: () => void;
handleUnattach: () => void;
handleMove: (target: MoveTarget) => void;
handleMoveToLibraryAt: () => void;
}
export interface UseCardContextMenuArgs {
gameId: number;
localPlayerId: number | null;
card: Data.ServerInfo_Card | null;
ownerPlayerId: number | null;
sourceZone: string | null;
onClose: () => void;
onRequestSetPT: () => void;
onRequestSetAnnotation: () => void;
onRequestSetCounter: () => void;
onRequestDrawArrow: () => void;
onRequestAttach: () => void;
onRequestMoveToLibraryAt: () => void;
}
export function useCardContextMenu({
gameId,
localPlayerId,
card,
ownerPlayerId,
sourceZone,
onClose,
onRequestSetPT,
onRequestSetAnnotation,
onRequestSetCounter,
onRequestDrawArrow,
onRequestAttach,
onRequestMoveToLibraryAt,
}: UseCardContextMenuArgs): CardContextMenu {
const webClient = useWebClient();
const ready = card != null && ownerPlayerId != null && sourceZone != null && localPlayerId != null;
// Mutating actions (tap, flip, counters, attrs, P/T, annotation, attach,
// move) require ownership of the card — matches desktop's
// `card_menu.cpp:151-161` which drops all mutators when the menu target
// isn't getLocalOrJudge()-modifiable. Read-only actions (Draw arrow)
// stay available for planning/communication.
const isOwnedByLocal = ready && ownerPlayerId === localPlayerId;
const isAttached = ready && (card!.attachCardId ?? -1) >= 0;
// Desktop's actAttach is only available from a table card; other zones
// never expose the attach arrow.
const canAttach = ready && sourceZone === App.ZoneName.TABLE;
const setAttr = (attribute: Data.CardAttribute, value: string) => {
if (!ready) {
return;
}
webClient.request.game.setCardAttr(gameId, {
zone: sourceZone!,
cardId: card!.id,
attribute,
attrValue: value,
});
};
const handleFlip = () => {
if (!ready) {
return;
}
// TODO(card-db): desktop's Player::actCardMenuFlip reads the card's stored
// P/T and forwards it so the revealed side shows the correct stats
// (cockatrice/src/game/player/player_actions.cpp:1805-1810). We can't
// do that without a card-database-by-name lookup, which isn't wired in
// the webclient yet. The server re-derives PT from the card DB for known
// names, so omitting `pt` is harmless for non-custom cards.
webClient.request.game.flipCard(gameId, {
zone: sourceZone!,
cardId: card!.id,
faceDown: !card!.faceDown,
});
onClose();
};
const handleTapToggle = () => {
if (!ready) {
return;
}
setAttr(Data.CardAttribute.AttrTapped, card!.tapped ? '0' : '1');
onClose();
};
const handleFaceDownToggle = () => {
if (!ready) {
return;
}
setAttr(Data.CardAttribute.AttrFaceDown, card!.faceDown ? '0' : '1');
onClose();
};
const handleDoesntUntapToggle = () => {
if (!ready) {
return;
}
setAttr(Data.CardAttribute.AttrDoesntUntap, card!.doesntUntap ? '0' : '1');
onClose();
};
const handleSetPT = () => {
onRequestSetPT();
onClose();
};
const handleSetAnnotation = () => {
onRequestSetAnnotation();
onClose();
};
const handleCardCounterDelta = (delta: number) => {
if (!ready) {
return;
}
webClient.request.game.incCardCounter(gameId, {
zone: sourceZone!,
cardId: card!.id,
counterId: 0,
counterDelta: delta,
});
onClose();
};
const handleSetCardCounter = () => {
onRequestSetCounter();
onClose();
};
const handleDrawArrow = () => {
onRequestDrawArrow();
onClose();
};
const handleAttach = () => {
onRequestAttach();
onClose();
};
const handleUnattach = () => {
if (!ready) {
return;
}
// Desktop's actUnattach sends only start_zone + card_id; the server uses
// proto2 presence (`has_target_player_id()`) to detect "detach". Setting
// targetPlayerId: -1 here would leave presence set and trip the attach
// code path server-side. MessageInitShape makes these fields optional,
// so omitting them produces an unset wire field.
webClient.request.game.attachCard(gameId, { startZone: sourceZone!, cardId: card!.id });
onClose();
};
const handleMove = (target: MoveTarget) => {
if (!ready) {
return;
}
// targetPlayerId is the ACTING player (local), matching desktop's
// Player::actMoveCardTo* which uses playerInfo->getId().
webClient.request.game.moveCard(gameId, {
startPlayerId: ownerPlayerId!,
startZone: sourceZone!,
cardsToMove: { card: [{ cardId: card!.id }] },
targetPlayerId: localPlayerId!,
targetZone: target.zone,
x: target.x,
y: target.y,
isReversed: false,
});
onClose();
};
const handleMoveToLibraryAt = () => {
onRequestMoveToLibraryAt();
onClose();
};
return {
ready,
isOwnedByLocal,
canAttach,
isAttached,
moveTargets: CARD_MOVE_TARGETS,
handleFlip,
handleTapToggle,
handleFaceDownToggle,
handleDoesntUntapToggle,
handleSetPT,
handleSetAnnotation,
handleCardCounterDelta,
handleSetCardCounter,
handleDrawArrow,
handleAttach,
handleUnattach,
handleMove,
handleMoveToLibraryAt,
};
}

View file

@ -1,12 +1,7 @@
import { useCallback, useId } from 'react';
import { useDraggable, useDroppable } from '@dnd-kit/core';
import { useScryfallCard } from '@app/hooks';
import { App } from '@app/types';
import type { Data } from '@app/types';
import { cx } from '@app/utils';
import { makeCardKey, useRegisterCardRef } from '../CardRegistry/CardRegistryContext';
import { useCardSlot } from './useCardSlot';
import './CardSlot.css';
@ -38,55 +33,13 @@ function CardSlot({
onContextMenu,
onMouseEnter,
}: CardSlotProps) {
const { smallUrl } = useScryfallCard(card);
// React-stable id salts the dnd-kit IDs so even two disabled CardSlots
// rendering the same card (during state transitions / hidden-zone leaks)
// never collide. Without the salt, pre-owner/zone render cycles shared
// `card-x-x-<id>` and dnd-kit warned.
const instanceId = useId();
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `card-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
data: { card, sourcePlayerId: ownerPlayerId, sourceZone: zone },
disabled: !draggable || ownerPlayerId == null || zone == null,
const { smallUrl, attributes, listeners, isDragging, isOver, rootRef } = useCardSlot({
card,
draggable,
ownerPlayerId,
zone,
});
// Cards on the battlefield double as drop targets for drag-to-attach.
// Other zones don't support attach (desktop's Player::actAttach rejects
// non-table targets), so the droppable is only live for TABLE.
const droppableEnabled =
ownerPlayerId != null && zone === App.ZoneName.TABLE;
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: `card-drop-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
data: {
attachTarget: true,
targetPlayerId: ownerPlayerId,
targetZone: zone,
targetCardId: card.id,
},
disabled: !droppableEnabled,
});
const registryKey =
ownerPlayerId != null && zone != null
? makeCardKey(ownerPlayerId, zone, card.id)
: null;
const registerRef = useRegisterCardRef(registryKey);
const rootRef = useCallback(
(el: HTMLElement | null) => {
registerRef(el);
if (draggable) {
setNodeRef(el);
}
if (droppableEnabled) {
setDropRef(el);
}
},
[registerRef, setNodeRef, setDropRef, draggable, droppableEnabled],
);
const className = cx('card-slot', {
'card-slot--tapped': card.tapped,
'card-slot--inverted': inverted,

View file

@ -0,0 +1,89 @@
import { useCallback, useId } from 'react';
import {
useDraggable,
useDroppable,
type DraggableAttributes,
type DraggableSyntheticListeners,
} from '@dnd-kit/core';
import { useScryfallCard } from '@app/hooks';
import { App } from '@app/types';
import type { Data } from '@app/types';
import { makeCardKey, useRegisterCardRef } from '../CardRegistry/CardRegistryContext';
export interface CardSlot {
smallUrl: string | null | undefined;
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners;
isDragging: boolean;
isOver: boolean;
rootRef: (el: HTMLElement | null) => void;
}
export interface UseCardSlotArgs {
card: Data.ServerInfo_Card;
draggable: boolean;
ownerPlayerId: number | undefined;
zone: string | undefined;
}
export function useCardSlot({ card, draggable, ownerPlayerId, zone }: UseCardSlotArgs): CardSlot {
const { smallUrl } = useScryfallCard(card);
// React-stable id salts the dnd-kit IDs so even two disabled CardSlots
// rendering the same card (during state transitions / hidden-zone leaks)
// never collide. Without the salt, pre-owner/zone render cycles shared
// `card-x-x-<id>` and dnd-kit warned.
const instanceId = useId();
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `card-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
data: { card, sourcePlayerId: ownerPlayerId, sourceZone: zone },
disabled: !draggable || ownerPlayerId == null || zone == null,
});
// Cards on the battlefield double as drop targets for drag-to-attach.
// Other zones don't support attach (desktop's Player::actAttach rejects
// non-table targets), so the droppable is only live for TABLE.
const droppableEnabled =
ownerPlayerId != null && zone === App.ZoneName.TABLE;
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: `card-drop-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
data: {
attachTarget: true,
targetPlayerId: ownerPlayerId,
targetZone: zone,
targetCardId: card.id,
},
disabled: !droppableEnabled,
});
const registryKey =
ownerPlayerId != null && zone != null
? makeCardKey(ownerPlayerId, zone, card.id)
: null;
const registerRef = useRegisterCardRef(registryKey);
const rootRef = useCallback(
(el: HTMLElement | null) => {
registerRef(el);
if (draggable) {
setNodeRef(el);
}
if (droppableEnabled) {
setDropRef(el);
}
},
[registerRef, setNodeRef, setDropRef, draggable, droppableEnabled],
);
return {
smallUrl,
attributes,
listeners,
isDragging,
isOver,
rootRef,
};
}

View file

@ -1,11 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
import type { Data, Enriched } from '@app/types';
import { makeCardKey, useCardRegistry } from '../CardRegistry/CardRegistryContext';
import { useGameArrowOverlay } from './useGameArrowOverlay';
import './GameArrowOverlay.css';
@ -15,108 +8,8 @@ export interface GameArrowOverlayProps {
dragPreview?: { x1: number; y1: number; x2: number; y2: number; color: string } | null;
}
interface ResolvedArrow {
arrowId: number;
ownerPlayerId: number;
x1: number;
y1: number;
x2: number;
y2: number;
color: string;
}
const ARROW_FALLBACK_CSS = App.rgbaToCss(App.ArrowColor.RED);
function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string {
if (!c) {
return ARROW_FALLBACK_CSS;
}
return App.rgbaToCss({ r: c.r, g: c.g, b: c.b, a: c.a ?? 255 });
}
function GameArrowOverlay({ gameId, boardRef, dragPreview = null }: GameArrowOverlayProps) {
const webClient = useWebClient();
const registry = useCardRegistry();
const players = useAppSelector((state) =>
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
);
// Tick is bumped whenever we need to re-query DOM rects (card registry
// mutation, board resize). Keeps the overlay declarative without an external
// layout engine.
const [tick, setTick] = useState(0);
const bump = useCallback(() => {
setTick((t) => t + 1);
}, []);
useEffect(() => {
if (!registry) {
return undefined;
}
return registry.subscribe(bump);
}, [registry, bump]);
// First-paint: the board ref is null during the initial render, so `boardRect`
// is undefined and the arrows memo bails out. Bump once after mount so the
// next render sees a populated ref.
useLayoutEffect(() => {
bump();
}, [bump]);
useLayoutEffect(() => {
const el = boardRef.current;
if (!el || typeof ResizeObserver === 'undefined') {
return undefined;
}
const ro = new ResizeObserver(() => bump());
ro.observe(el);
return () => ro.disconnect();
}, [boardRef, bump]);
const boardRect = boardRef.current?.getBoundingClientRect();
const arrows = useMemo<ResolvedArrow[]>(() => {
if (!players || !registry || !boardRect) {
return [];
}
const out: ResolvedArrow[] = [];
for (const player of Object.values(players) as Enriched.PlayerEntry[]) {
for (const a of Object.values(player.arrows) as Data.ServerInfo_Arrow[]) {
const sourceEl = registry.get(
makeCardKey(a.startPlayerId, a.startZone, a.startCardId),
);
const targetEl = registry.get(
makeCardKey(a.targetPlayerId, a.targetZone, a.targetCardId),
);
if (!sourceEl || !targetEl) {
continue;
}
const s = sourceEl.getBoundingClientRect();
const t = targetEl.getBoundingClientRect();
out.push({
arrowId: a.id,
ownerPlayerId: player.properties.playerId,
x1: s.left + s.width / 2 - boardRect.left,
y1: s.top + s.height / 2 - boardRect.top,
x2: t.left + t.width / 2 - boardRect.left,
y2: t.top + t.height / 2 - boardRect.top,
color: cssColor(a.arrowColor),
});
}
}
// `tick` in deps intentionally re-runs the memo on DOM-layout changes.
return out;
}, [players, registry, boardRect, tick]);
const handleArrowClick = (arrowId: number) => {
if (gameId == null) {
return;
}
webClient.request.game.deleteArrow(gameId, { arrowId });
};
const width = boardRect?.width ?? 0;
const height = boardRect?.height ?? 0;
const { arrows, width, height, handleArrowClick } = useGameArrowOverlay({ gameId, boardRef });
return (
<svg

View file

@ -0,0 +1,129 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
import type { Data, Enriched } from '@app/types';
import { makeCardKey, useCardRegistry } from '../CardRegistry/CardRegistryContext';
export interface ResolvedArrow {
arrowId: number;
ownerPlayerId: number;
x1: number;
y1: number;
x2: number;
y2: number;
color: string;
}
const ARROW_FALLBACK_CSS = App.rgbaToCss(App.ArrowColor.RED);
function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string {
if (!c) {
return ARROW_FALLBACK_CSS;
}
return App.rgbaToCss({ r: c.r, g: c.g, b: c.b, a: c.a ?? 255 });
}
export interface GameArrowOverlay {
arrows: ResolvedArrow[];
width: number;
height: number;
handleArrowClick: (arrowId: number) => void;
}
export interface UseGameArrowOverlayArgs {
gameId: number | undefined;
boardRef: React.RefObject<HTMLElement | null>;
}
export function useGameArrowOverlay({
gameId,
boardRef,
}: UseGameArrowOverlayArgs): GameArrowOverlay {
const webClient = useWebClient();
const registry = useCardRegistry();
const players = useAppSelector((state) =>
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
);
// Tick is bumped whenever we need to re-query DOM rects (card registry
// mutation, board resize). Keeps the overlay declarative without an external
// layout engine.
const [tick, setTick] = useState(0);
const bump = useCallback(() => {
setTick((t) => t + 1);
}, []);
useEffect(() => {
if (!registry) {
return undefined;
}
return registry.subscribe(bump);
}, [registry, bump]);
// First-paint: the board ref is null during the initial render, so `boardRect`
// is undefined and the arrows memo bails out. Bump once after mount so the
// next render sees a populated ref.
useLayoutEffect(() => {
bump();
}, [bump]);
useLayoutEffect(() => {
const el = boardRef.current;
if (!el || typeof ResizeObserver === 'undefined') {
return undefined;
}
const ro = new ResizeObserver(() => bump());
ro.observe(el);
return () => ro.disconnect();
}, [boardRef, bump]);
const boardRect = boardRef.current?.getBoundingClientRect();
const arrows = useMemo<ResolvedArrow[]>(() => {
if (!players || !registry || !boardRect) {
return [];
}
const out: ResolvedArrow[] = [];
for (const player of Object.values(players) as Enriched.PlayerEntry[]) {
for (const a of Object.values(player.arrows) as Data.ServerInfo_Arrow[]) {
const sourceEl = registry.get(
makeCardKey(a.startPlayerId, a.startZone, a.startCardId),
);
const targetEl = registry.get(
makeCardKey(a.targetPlayerId, a.targetZone, a.targetCardId),
);
if (!sourceEl || !targetEl) {
continue;
}
const s = sourceEl.getBoundingClientRect();
const t = targetEl.getBoundingClientRect();
out.push({
arrowId: a.id,
ownerPlayerId: player.properties.playerId,
x1: s.left + s.width / 2 - boardRect.left,
y1: s.top + s.height / 2 - boardRect.top,
x2: t.left + t.width / 2 - boardRect.left,
y2: t.top + t.height / 2 - boardRect.top,
color: cssColor(a.arrowColor),
});
}
}
// `tick` in deps intentionally re-runs the memo on DOM-layout changes.
return out;
}, [players, registry, boardRect, tick]);
const handleArrowClick = (arrowId: number) => {
if (gameId == null) {
return;
}
webClient.request.game.deleteArrow(gameId, { arrowId });
};
const width = boardRect?.width ?? 0;
const height = boardRect?.height ?? 0;
return { arrows, width, height, handleArrowClick };
}

View file

@ -1,92 +1,24 @@
import { useEffect, useRef, useState } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import type { Enriched } from '@app/types';
import { useRef } from 'react';
import { formatElapsed, useGameLog } from './useGameLog';
import './GameLog.css';
const EMPTY_MESSAGES: Enriched.GameMessage[] = [];
function formatElapsed(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
const ss = String(s % 60).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
export interface GameLogProps {
gameId: number | undefined;
}
function GameLog({ gameId }: GameLogProps) {
const webClient = useWebClient();
const messages = useAppSelector((state) =>
gameId != null ? GameSelectors.getMessages(state, gameId) : EMPTY_MESSAGES,
);
const players = useAppSelector((state) =>
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
);
const secondsElapsed = useAppSelector((state) =>
gameId != null ? GameSelectors.getSecondsElapsed(state, gameId) : 0,
);
// Local 1Hz ticker, resynced from Redux whenever a server event delivers a
// fresh `secondsElapsed`. Mirrors desktop's QTimer(1000) +
// setGameTime(event.seconds_elapsed()) pattern in game_state.cpp.
const [displaySeconds, setDisplaySeconds] = useState(secondsElapsed);
useEffect(() => {
setDisplaySeconds(secondsElapsed);
}, [secondsElapsed]);
useEffect(() => {
if (gameId == null) {
return undefined;
}
const id = window.setInterval(() => {
setDisplaySeconds((prev) => prev + 1);
}, 1000);
return () => window.clearInterval(id);
}, [gameId]);
const [draft, setDraft] = useState('');
const listRef = useRef<HTMLDivElement>(null);
// Desktop pins the log to the bottom unless the user has scrolled up to read backlog.
// Capture pin state before the new line renders so auto-scroll only fires when the
// user was already following the tail.
const wasPinnedRef = useRef(true);
useEffect(() => {
const el = listRef.current;
if (!el) {
return;
}
if (wasPinnedRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [messages.length]);
const handleMessagesScroll = () => {
const el = listRef.current;
if (!el) {
return;
}
wasPinnedRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 2;
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (gameId == null) {
return;
}
const trimmed = draft.trim();
if (trimmed.length === 0) {
return;
}
webClient.request.game.gameSay(gameId, { message: trimmed });
setDraft('');
};
const {
messages,
players,
displaySeconds,
draft,
setDraft,
handleMessagesScroll,
handleSubmit,
} = useGameLog({ gameId, listRef });
return (
<div className="game-log" data-testid="game-log">

View file

@ -0,0 +1,111 @@
import { useEffect, useRef, useState, RefObject } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import type { Enriched } from '@app/types';
const EMPTY_MESSAGES: Enriched.GameMessage[] = [];
export function formatElapsed(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
const ss = String(s % 60).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
export interface GameLog {
messages: Enriched.GameMessage[];
players: Record<number, Enriched.PlayerEntry> | undefined;
displaySeconds: number;
draft: string;
setDraft: (v: string) => void;
handleMessagesScroll: () => void;
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
}
export interface UseGameLogArgs {
gameId: number | undefined;
listRef: RefObject<HTMLDivElement | null>;
}
export function useGameLog({ gameId, listRef }: UseGameLogArgs): GameLog {
const webClient = useWebClient();
// getMessages falls back to a shared EMPTY_ARRAY typed as ServerInfo_Card[]
// (see game.selectors.ts). The runtime array is empty, so the cast is safe;
// fixing the selector's fallback type is out of scope for this refactor.
const messages = useAppSelector((state) =>
gameId != null ? GameSelectors.getMessages(state, gameId) : EMPTY_MESSAGES,
) as Enriched.GameMessage[];
const players = useAppSelector((state) =>
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
);
const secondsElapsed = useAppSelector((state) =>
gameId != null ? GameSelectors.getSecondsElapsed(state, gameId) : 0,
);
// Local 1Hz ticker, resynced from Redux whenever a server event delivers a
// fresh `secondsElapsed`. Mirrors desktop's QTimer(1000) +
// setGameTime(event.seconds_elapsed()) pattern in game_state.cpp.
const [displaySeconds, setDisplaySeconds] = useState(secondsElapsed);
useEffect(() => {
setDisplaySeconds(secondsElapsed);
}, [secondsElapsed]);
useEffect(() => {
if (gameId == null) {
return undefined;
}
const id = window.setInterval(() => {
setDisplaySeconds((prev) => prev + 1);
}, 1000);
return () => window.clearInterval(id);
}, [gameId]);
const [draft, setDraft] = useState('');
// Desktop pins the log to the bottom unless the user has scrolled up to read backlog.
// Capture pin state before the new line renders so auto-scroll only fires when the
// user was already following the tail.
const wasPinnedRef = useRef(true);
useEffect(() => {
const el = listRef.current;
if (!el) {
return;
}
if (wasPinnedRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [messages.length, listRef]);
const handleMessagesScroll = () => {
const el = listRef.current;
if (!el) {
return;
}
wasPinnedRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 2;
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (gameId == null) {
return;
}
const trimmed = draft.trim();
if (trimmed.length === 0) {
return;
}
webClient.request.game.gameSay(gameId, { message: trimmed });
setDraft('');
};
return {
messages,
players,
displaySeconds,
draft,
setDraft,
handleMessagesScroll,
handleSubmit,
};
}

View file

@ -2,7 +2,7 @@ import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import { useWebClient } from '@app/hooks';
import { useHandContextMenu } from './useHandContextMenu';
import './HandContextMenu.css';
@ -27,51 +27,15 @@ function HandContextMenu({
onRequestRevealHand,
onRequestRevealRandom,
}: HandContextMenuProps) {
const webClient = useWebClient();
const handleChoose = () => {
if (gameId <= 0) {
return;
}
onRequestChooseMulligan();
onClose();
};
const handleSameSize = () => {
if (gameId <= 0) {
return;
}
webClient.request.game.mulligan(gameId, { number: handSize });
onClose();
};
const handleMinusOne = () => {
if (gameId <= 0) {
return;
}
// Desktop's actMulliganMinusOne floors at 1 (see
// cockatrice/src/game/player/player_actions.cpp actMulliganMinusOne);
// the server-side doMulligan rejects number < 1.
const next = Math.max(1, handSize - 1);
webClient.request.game.mulligan(gameId, { number: next });
onClose();
};
const handleRevealHand = () => {
if (gameId <= 0) {
return;
}
onRequestRevealHand();
onClose();
};
const handleRevealRandom = () => {
if (gameId <= 0) {
return;
}
onRequestRevealRandom();
onClose();
};
const { handleChoose, handleSameSize, handleMinusOne, handleRevealHand, handleRevealRandom } =
useHandContextMenu({
gameId,
handSize,
onClose,
onRequestChooseMulligan,
onRequestRevealHand,
onRequestRevealRandom,
});
return (
<Menu

View file

@ -0,0 +1,75 @@
import { useWebClient } from '@app/hooks';
export interface HandContextMenu {
handleChoose: () => void;
handleSameSize: () => void;
handleMinusOne: () => void;
handleRevealHand: () => void;
handleRevealRandom: () => void;
}
export interface UseHandContextMenuArgs {
gameId: number;
handSize: number;
onClose: () => void;
onRequestChooseMulligan: () => void;
onRequestRevealHand: () => void;
onRequestRevealRandom: () => void;
}
export function useHandContextMenu({
gameId,
handSize,
onClose,
onRequestChooseMulligan,
onRequestRevealHand,
onRequestRevealRandom,
}: UseHandContextMenuArgs): HandContextMenu {
const webClient = useWebClient();
const handleChoose = () => {
if (gameId <= 0) {
return;
}
onRequestChooseMulligan();
onClose();
};
const handleSameSize = () => {
if (gameId <= 0) {
return;
}
webClient.request.game.mulligan(gameId, { number: handSize });
onClose();
};
const handleMinusOne = () => {
if (gameId <= 0) {
return;
}
// Desktop's actMulliganMinusOne floors at 1 (see
// cockatrice/src/game/player/player_actions.cpp actMulliganMinusOne);
// the server-side doMulligan rejects number < 1.
const next = Math.max(1, handSize - 1);
webClient.request.game.mulligan(gameId, { number: next });
onClose();
};
const handleRevealHand = () => {
if (gameId <= 0) {
return;
}
onRequestRevealHand();
onClose();
};
const handleRevealRandom = () => {
if (gameId <= 0) {
return;
}
onRequestRevealRandom();
onClose();
};
return { handleChoose, handleSameSize, handleMinusOne, handleRevealHand, handleRevealRandom };
}

View file

@ -1,10 +1,9 @@
import { useDroppable } from '@dnd-kit/core';
import { App, Data } from '@app/types';
import { GameSelectors, useAppSelector } from '@app/store';
import { cx } from '@app/utils';
import CardSlot from '../CardSlot/CardSlot';
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
import { useHandZone } from './useHandZone';
import './HandZone.css';
@ -29,33 +28,13 @@ function HandZone({
onCardContextMenu,
onZoneContextMenu,
}: HandZoneProps) {
const cards = useAppSelector((state) =>
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.HAND),
);
// Match desktop: can't drop into a hand zone that isn't yours (judges
// aside; server enforces the same restriction). Today only the local
// HandZone mounts, but this guard future-proofs opponent-hand mirrors.
const { setNodeRef, isOver } = useDroppable({
id: `hand-${playerId}`,
data: { targetPlayerId: playerId, targetZone: App.ZoneName.HAND },
disabled: !canAct,
const { cards, setNodeRef, isOver, handleZoneContextMenu } = useHandZone({
gameId,
playerId,
canAct,
onZoneContextMenu,
});
// Right-click anywhere inside the hand that doesn't land on a card opens
// the hand zone context menu (mulligan / reveal hand). Card-level right-
// click has its own handler on CardSlot.
const handleZoneContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
if (!onZoneContextMenu) {
return;
}
const target = e.target as HTMLElement;
if (target.closest('[data-card-id]')) {
return;
}
onZoneContextMenu(e);
};
return (
<div
ref={setNodeRef}

View file

@ -0,0 +1,55 @@
import { useDroppable } from '@dnd-kit/core';
import type { Ref } from 'react';
import { App, Data } from '@app/types';
import { GameSelectors, useAppSelector } from '@app/store';
export interface HandZone {
cards: Data.ServerInfo_Card[];
setNodeRef: Ref<HTMLDivElement>;
isOver: boolean;
handleZoneContextMenu: (e: React.MouseEvent<HTMLDivElement>) => void;
}
export interface UseHandZoneArgs {
gameId: number;
playerId: number;
canAct: boolean;
onZoneContextMenu?: (event: React.MouseEvent) => void;
}
export function useHandZone({
gameId,
playerId,
canAct,
onZoneContextMenu,
}: UseHandZoneArgs): HandZone {
const cards = useAppSelector((state) =>
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.HAND),
);
// Match desktop: can't drop into a hand zone that isn't yours (judges
// aside; server enforces the same restriction). Today only the local
// HandZone mounts, but this guard future-proofs opponent-hand mirrors.
const { setNodeRef, isOver } = useDroppable({
id: `hand-${playerId}`,
data: { targetPlayerId: playerId, targetZone: App.ZoneName.HAND },
disabled: !canAct,
});
// Right-click anywhere inside the hand that doesn't land on a card opens
// the hand zone context menu (mulligan / reveal hand). Card-level right-
// click has its own handler on CardSlot.
const handleZoneContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
if (!onZoneContextMenu) {
return;
}
const target = e.target as HTMLElement;
if (target.closest('[data-card-id]')) {
return;
}
onZoneContextMenu(e);
};
return { cards, setNodeRef, isOver, handleZoneContextMenu };
}

View file

@ -1,10 +1,10 @@
import Tooltip from '@mui/material/Tooltip';
import { useCurrentGame, useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App, Data } from '@app/types';
import { App } from '@app/types';
import { cx } from '@app/utils';
import { usePhaseBar } from './usePhaseBar';
import './PhaseBar.css';
export interface PhaseBarProps {
@ -35,65 +35,8 @@ const PHASE_LABELS: ReadonlyArray<{
];
function PhaseBar({ gameId }: PhaseBarProps) {
const webClient = useWebClient();
const { game, isJudge, isStarted } = useCurrentGame(gameId);
const activePhase = useAppSelector((state) =>
gameId != null ? GameSelectors.getActivePhase(state, gameId) : undefined,
);
const localPlayerId = game?.localPlayerId;
const tableCards = useAppSelector((state) =>
gameId != null && localPlayerId != null
? GameSelectors.getCards(state, gameId, localPlayerId, App.ZoneName.TABLE)
: undefined,
);
// Desktop: only the active player (or a judge) can advance the phase.
const canAdvance =
gameId != null &&
game != null &&
isStarted &&
(isJudge || game.activePlayerId === game.localPlayerId);
const handlePhaseClick = (phase: App.Phase) => {
if (!canAdvance || gameId == null) {
return;
}
webClient.request.game.setActivePhase(gameId, { phase });
};
const handlePass = () => {
if (!canAdvance || gameId == null) {
return;
}
webClient.request.game.nextTurn(gameId);
};
// Desktop's untap-step double-click fires "Untap All" on the local player's
// table zone (cockatrice/src/game/player/player_actions.cpp actUntapAll).
// We replicate by sending one setCardAttr per tapped card; there is no
// batch variant on the wire.
const handleUntapAll = () => {
if (!canAdvance || gameId == null || !tableCards) {
return;
}
for (const card of tableCards) {
if (card.tapped) {
webClient.request.game.setCardAttr(gameId, {
zone: App.ZoneName.TABLE,
cardId: card.id,
attribute: Data.CardAttribute.AttrTapped,
attrValue: '0',
});
}
}
};
const handleDrawOne = () => {
if (!canAdvance || gameId == null) {
return;
}
webClient.request.game.drawCards(gameId, { number: 1 });
};
const { activePhase, canAdvance, handlePhaseClick, handlePass, handleUntapAll, handleDrawOne } =
usePhaseBar(gameId);
const onDoubleClickFor = (kind: 'untapAll' | 'drawCard' | undefined) => {
if (kind === 'untapAll') {

View file

@ -0,0 +1,76 @@
import { useCurrentGame, useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App, Data } from '@app/types';
export interface PhaseBar {
activePhase: App.Phase | undefined;
canAdvance: boolean;
handlePhaseClick: (phase: App.Phase) => void;
handlePass: () => void;
handleUntapAll: () => void;
handleDrawOne: () => void;
}
export function usePhaseBar(gameId: number | undefined): PhaseBar {
const webClient = useWebClient();
const { game, isJudge, isStarted } = useCurrentGame(gameId);
const activePhase = useAppSelector((state) =>
gameId != null ? GameSelectors.getActivePhase(state, gameId) : undefined,
);
const localPlayerId = game?.localPlayerId;
const tableCards = useAppSelector((state) =>
gameId != null && localPlayerId != null
? GameSelectors.getCards(state, gameId, localPlayerId, App.ZoneName.TABLE)
: undefined,
);
// Desktop: only the active player (or a judge) can advance the phase.
const canAdvance =
gameId != null &&
game != null &&
isStarted &&
(isJudge || game.activePlayerId === game.localPlayerId);
const handlePhaseClick = (phase: App.Phase) => {
if (!canAdvance || gameId == null) {
return;
}
webClient.request.game.setActivePhase(gameId, { phase });
};
const handlePass = () => {
if (!canAdvance || gameId == null) {
return;
}
webClient.request.game.nextTurn(gameId);
};
// Desktop's untap-step double-click fires "Untap All" on the local player's
// table zone (cockatrice/src/game/player/player_actions.cpp actUntapAll).
// We replicate by sending one setCardAttr per tapped card; there is no
// batch variant on the wire.
const handleUntapAll = () => {
if (!canAdvance || gameId == null || !tableCards) {
return;
}
for (const card of tableCards) {
if (card.tapped) {
webClient.request.game.setCardAttr(gameId, {
zone: App.ZoneName.TABLE,
cardId: card.id,
attribute: Data.CardAttribute.AttrTapped,
attrValue: '0',
});
}
}
};
const handleDrawOne = () => {
if (!canAdvance || gameId == null) {
return;
}
webClient.request.game.drawCards(gameId, { number: 1 });
};
return { activePhase, canAdvance, handlePhaseClick, handlePass, handleUntapAll, handleDrawOne };
}

View file

@ -1,9 +1,8 @@
import { useState } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { cx } from '@app/utils';
import type { Data } from '@app/types';
import { cssColor, usePlayerInfoPanel } from './usePlayerInfoPanel';
import './PlayerInfoPanel.css';
export interface PlayerInfoPanelProps {
@ -14,21 +13,6 @@ export interface PlayerInfoPanelProps {
onContextMenu?: (event: React.MouseEvent) => void;
}
function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string {
if (!c) {
return '#666';
}
return `rgba(${c.r}, ${c.g}, ${c.b}, ${(c.a ?? 255) / 255})`;
}
// Desktop renders Life larger/bolder than other counters (see
// cockatrice/src/game/player/player.cpp PlayerTarget sizing). We special-
// case the counter whose name is exactly 'Life' (case-insensitive) and
// pull it out of the regular counter list into a prominent life block.
function isLifeCounter(c: { name: string }): boolean {
return c.name.trim().toLowerCase() === 'life';
}
function PlayerInfoPanel({
gameId,
playerId,
@ -36,13 +20,20 @@ function PlayerInfoPanel({
onRequestCreateCounter,
onContextMenu,
}: PlayerInfoPanelProps) {
const webClient = useWebClient();
const player = useAppSelector((state) => GameSelectors.getPlayer(state, gameId, playerId));
const counters = useAppSelector((state) => GameSelectors.getCounters(state, gameId, playerId));
const hostId = useAppSelector((state) => GameSelectors.getHostId(state, gameId));
const [editingId, setEditingId] = useState<number | null>(null);
const [editDraft, setEditDraft] = useState('');
const {
player,
isHost,
lifeCounter,
otherCounters,
editingId,
editDraft,
setEditDraft,
beginEdit,
commitEdit,
cancelEdit,
handleIncrement,
handleDelete,
} = usePlayerInfoPanel({ gameId, playerId });
if (!player) {
return <div className="player-info-panel player-info-panel--empty" />;
@ -53,44 +44,6 @@ function PlayerInfoPanel({
const conceded = player.properties.conceded;
const ready = player.properties.readyStart;
const sideboardLocked = player.properties.sideboardLocked ?? false;
const isHost = hostId != null && hostId === playerId;
const allCounters = Object.values(counters);
const lifeCounter = allCounters.find(isLifeCounter);
const otherCounters = allCounters.filter((c) => !isLifeCounter(c));
const handleIncrement = (counterId: number, delta: number) => {
webClient.request.game.incCounter(gameId, { counterId, delta });
};
const handleDelete = (counterId: number) => {
webClient.request.game.delCounter(gameId, { counterId });
};
const beginEdit = (counterId: number, currentValue: number) => {
setEditingId(counterId);
setEditDraft(String(currentValue));
};
const commitEdit = (counterId: number) => {
const trimmed = editDraft.trim();
// Empty input cancels the edit (desktop inline edits treat blur-with-
// no-change and blur-with-empty-string identically). Prior behavior
// coerced '' → 0 because `Number('')` is 0 and `Number.isInteger(0)` is
// true, which surprised users expecting cancel-on-blank.
if (trimmed.length === 0) {
setEditingId(null);
return;
}
const value = Number(trimmed);
if (Number.isInteger(value)) {
webClient.request.game.setCounter(gameId, { counterId, value });
}
setEditingId(null);
};
const cancelEdit = () => {
setEditingId(null);
};
const renderCounterRow = (c: Data.ServerInfo_Counter) => (
<li

View file

@ -0,0 +1,106 @@
import { useState } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import type { Data, Enriched } from '@app/types';
export function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string {
if (!c) {
return '#666';
}
return `rgba(${c.r}, ${c.g}, ${c.b}, ${(c.a ?? 255) / 255})`;
}
// Desktop renders Life larger/bolder than other counters (see
// cockatrice/src/game/player/player.cpp PlayerTarget sizing). We special-
// case the counter whose name is exactly 'Life' (case-insensitive) and
// pull it out of the regular counter list into a prominent life block.
export function isLifeCounter(c: { name: string }): boolean {
return c.name.trim().toLowerCase() === 'life';
}
export interface PlayerInfoPanel {
player: Enriched.PlayerEntry | undefined;
isHost: boolean;
lifeCounter: Data.ServerInfo_Counter | undefined;
otherCounters: Data.ServerInfo_Counter[];
editingId: number | null;
editDraft: string;
setEditDraft: (v: string) => void;
beginEdit: (counterId: number, currentValue: number) => void;
commitEdit: (counterId: number) => void;
cancelEdit: () => void;
handleIncrement: (counterId: number, delta: number) => void;
handleDelete: (counterId: number) => void;
}
export interface UsePlayerInfoPanelArgs {
gameId: number;
playerId: number;
}
export function usePlayerInfoPanel({
gameId,
playerId,
}: UsePlayerInfoPanelArgs): PlayerInfoPanel {
const webClient = useWebClient();
const player = useAppSelector((state) => GameSelectors.getPlayer(state, gameId, playerId));
const counters = useAppSelector((state) => GameSelectors.getCounters(state, gameId, playerId));
const hostId = useAppSelector((state) => GameSelectors.getHostId(state, gameId));
const [editingId, setEditingId] = useState<number | null>(null);
const [editDraft, setEditDraft] = useState('');
const isHost = hostId != null && hostId === playerId;
const allCounters = Object.values(counters);
const lifeCounter = allCounters.find(isLifeCounter);
const otherCounters = allCounters.filter((c) => !isLifeCounter(c));
const handleIncrement = (counterId: number, delta: number) => {
webClient.request.game.incCounter(gameId, { counterId, delta });
};
const handleDelete = (counterId: number) => {
webClient.request.game.delCounter(gameId, { counterId });
};
const beginEdit = (counterId: number, currentValue: number) => {
setEditingId(counterId);
setEditDraft(String(currentValue));
};
const commitEdit = (counterId: number) => {
const trimmed = editDraft.trim();
// Empty input cancels the edit (desktop inline edits treat blur-with-
// no-change and blur-with-empty-string identically). Prior behavior
// coerced '' → 0 because `Number('')` is 0 and `Number.isInteger(0)` is
// true, which surprised users expecting cancel-on-blank.
if (trimmed.length === 0) {
setEditingId(null);
return;
}
const value = Number(trimmed);
if (Number.isInteger(value)) {
webClient.request.game.setCounter(gameId, { counterId, value });
}
setEditingId(null);
};
const cancelEdit = () => {
setEditingId(null);
};
return {
player,
isHost,
lifeCounter,
otherCounters,
editingId,
editDraft,
setEditDraft,
beginEdit,
commitEdit,
cancelEdit,
handleIncrement,
handleDelete,
};
}

View file

@ -1,9 +1,7 @@
import { useMemo, useState } from 'react';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { useTurnControls } from './useTurnControls';
import './TurnControls.css';
@ -26,129 +24,31 @@ function TurnControls({
onToggleRotate90,
isRotated,
}: TurnControlsProps) {
const webClient = useWebClient();
const { game, localPlayer, isSpectator, isJudge, isHost, isStarted } = useCurrentGame(gameId);
const { status: settingsStatus, value: settings, update: updateSettings } = useSettings();
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
// Post-kick: the reducer has deleted the game from state but the dialog
// may still be mounted for a frame while `useGameLifecycle` navigates to
// /server. Every handler double-checks `game` so a trailing click can't
// fire a command against a game the server no longer has.
const hasLiveGame = gameId != null && game != null;
const [kickAnchor, setKickAnchor] = useState<HTMLElement | null>(null);
const opponents = useMemo(() => {
if (!game) {
return [];
}
return Object.values(game.players)
.filter((p) => p.properties.playerId !== game.localPlayerId)
.map((p) => ({
playerId: p.properties.playerId,
name: p.properties.userInfo?.name ?? `p${p.properties.playerId}`,
}));
}, [game]);
// Local arrows belong to `localPlayerId`; Remove Local Arrows iterates
// and deletes each one. Matches desktop's Player::actRemoveLocalArrows.
const localArrows = useAppSelector((state) =>
gameId != null && game != null
? GameSelectors.getArrows(state, gameId, game.localPlayerId)
: undefined,
);
const localArrowIds = useMemo(
() => (localArrows ? Object.keys(localArrows).map(Number) : []),
[localArrows],
);
// Players (judge or not) act as participants; pure spectators don't.
// Matches desktop: aConcede/aNextTurn are disabled when isSpectator() without
// judge privileges (see tab_game.cpp concede enablement + player_menu.cpp
// getLocalOrJudge gates).
const isParticipant = gameId != null && game != null && !isSpectator;
const isConceded = localPlayer?.properties.conceded ?? false;
const canAdvance =
gameId != null && game != null && isStarted &&
(isJudge || game.activePlayerId === game.localPlayerId);
const canLeave = gameId != null && game != null;
const canConcede = isParticipant && !isConceded;
const canUnconcede = isParticipant && isConceded;
// Rolling dice is a player action; judges may also roll. Pure spectators
// cannot (desktop exposes it through the player menu, which spectators
// don't receive).
const canRoll = gameId != null && (isParticipant || isJudge);
const canKick = gameId != null && isHost && opponents.length > 0;
const canRemoveArrows = hasLiveGame && localArrowIds.length > 0;
const handlePassTurn = () => {
if (!canAdvance || !hasLiveGame) {
return;
}
webClient.request.game.nextTurn(gameId);
};
const handleReverseTurn = () => {
if (!canAdvance || !hasLiveGame) {
return;
}
webClient.request.game.reverseTurn(gameId);
};
const handleNextPhase = () => {
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
// Untap (0).
const current = game.activePhase;
const next = current >= 0 ? (current + 1) % 11 : 0;
webClient.request.game.setActivePhase(gameId, { phase: next });
};
const handleConcedeToggle = () => {
if (!hasLiveGame || (!canConcede && !canUnconcede)) {
return;
}
if (isConceded) {
onRequestUnconcede();
} else {
onRequestConcede();
}
};
const handleRemoveArrows = () => {
if (!canRemoveArrows) {
return;
}
for (const arrowId of localArrowIds) {
webClient.request.game.deleteArrow(gameId, { arrowId });
}
};
const handleLeave = () => {
if (!canLeave || !hasLiveGame) {
return;
}
webClient.request.game.leaveGame(gameId);
};
const handleToggleInvert = () => {
if (settingsStatus !== LoadingState.READY) {
return;
}
void updateSettings({ invertVerticalCoordinate: !invertVerticalCoordinate });
};
const handleKick = (playerId: number) => {
if (!hasLiveGame) {
return;
}
webClient.request.game.kickFromGame(gameId, { playerId });
setKickAnchor(null);
};
const {
isHost,
isConceded,
invertVerticalCoordinate,
settingsReady,
canAdvance,
canLeave,
canConcede,
canUnconcede,
canRoll,
canKick,
canRemoveArrows,
hasLiveGame,
opponents,
kickAnchor,
setKickAnchor,
handlePassTurn,
handleReverseTurn,
handleNextPhase,
handleConcedeToggle,
handleRemoveArrows,
handleLeave,
handleToggleInvert,
handleKick,
} = useTurnControls({ gameId, onRequestConcede, onRequestUnconcede });
return (
<div className="turn-controls" data-testid="turn-controls">
@ -216,7 +116,7 @@ function TurnControls({
className={`turn-controls__btn${invertVerticalCoordinate ? ' turn-controls__btn--active' : ''}`}
onClick={handleToggleInvert}
aria-pressed={invertVerticalCoordinate}
disabled={settingsStatus !== LoadingState.READY}
disabled={!settingsReady}
title="Flip battlefield row order (saved across sessions)"
>
Invert Rows

View file

@ -0,0 +1,197 @@
import { useMemo, useState } from 'react';
import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
export interface TurnControlsOpponent {
playerId: number;
name: string;
}
export interface TurnControls {
isHost: boolean;
isConceded: boolean;
invertVerticalCoordinate: boolean;
settingsReady: boolean;
canAdvance: boolean;
canLeave: boolean;
canConcede: boolean;
canUnconcede: boolean;
canRoll: boolean;
canKick: boolean;
canRemoveArrows: boolean;
hasLiveGame: boolean;
opponents: TurnControlsOpponent[];
kickAnchor: HTMLElement | null;
setKickAnchor: (el: HTMLElement | null) => void;
handlePassTurn: () => void;
handleReverseTurn: () => void;
handleNextPhase: () => void;
handleConcedeToggle: () => void;
handleRemoveArrows: () => void;
handleLeave: () => void;
handleToggleInvert: () => void;
handleKick: (playerId: number) => void;
}
export interface UseTurnControlsArgs {
gameId: number | undefined;
onRequestConcede: () => void;
onRequestUnconcede: () => void;
}
export function useTurnControls({
gameId,
onRequestConcede,
onRequestUnconcede,
}: UseTurnControlsArgs): TurnControls {
const webClient = useWebClient();
const { game, localPlayer, isSpectator, isJudge, isHost, isStarted } = useCurrentGame(gameId);
const { status: settingsStatus, value: settings, update: updateSettings } = useSettings();
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
// Post-kick: the reducer has deleted the game from state but the dialog
// may still be mounted for a frame while `useGameLifecycle` navigates to
// /server. Every handler double-checks `game` so a trailing click can't
// fire a command against a game the server no longer has.
const hasLiveGame = gameId != null && game != null;
const [kickAnchor, setKickAnchor] = useState<HTMLElement | null>(null);
const opponents = useMemo<TurnControlsOpponent[]>(() => {
if (!game) {
return [];
}
return Object.values(game.players)
.filter((p) => p.properties.playerId !== game.localPlayerId)
.map((p) => ({
playerId: p.properties.playerId,
name: p.properties.userInfo?.name ?? `p${p.properties.playerId}`,
}));
}, [game]);
// Local arrows belong to `localPlayerId`; Remove Local Arrows iterates
// and deletes each one. Matches desktop's Player::actRemoveLocalArrows.
const localArrows = useAppSelector((state) =>
gameId != null && game != null
? GameSelectors.getArrows(state, gameId, game.localPlayerId)
: undefined,
);
const localArrowIds = useMemo(
() => (localArrows ? Object.keys(localArrows).map(Number) : []),
[localArrows],
);
// Players (judge or not) act as participants; pure spectators don't.
// Matches desktop: aConcede/aNextTurn are disabled when isSpectator() without
// judge privileges (see tab_game.cpp concede enablement + player_menu.cpp
// getLocalOrJudge gates).
const isParticipant = gameId != null && game != null && !isSpectator;
const isConceded = localPlayer?.properties.conceded ?? false;
const canAdvance =
gameId != null && game != null && isStarted &&
(isJudge || game.activePlayerId === game.localPlayerId);
const canLeave = gameId != null && game != null;
const canConcede = isParticipant && !isConceded;
const canUnconcede = isParticipant && isConceded;
// Rolling dice is a player action; judges may also roll. Pure spectators
// cannot (desktop exposes it through the player menu, which spectators
// don't receive).
const canRoll = gameId != null && (isParticipant || isJudge);
const canKick = gameId != null && isHost && opponents.length > 0;
const canRemoveArrows = hasLiveGame && localArrowIds.length > 0;
const handlePassTurn = () => {
if (!canAdvance || !hasLiveGame) {
return;
}
webClient.request.game.nextTurn(gameId);
};
const handleReverseTurn = () => {
if (!canAdvance || !hasLiveGame) {
return;
}
webClient.request.game.reverseTurn(gameId);
};
const handleNextPhase = () => {
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
// Untap (0).
const current = game.activePhase;
const next = current >= 0 ? (current + 1) % 11 : 0;
webClient.request.game.setActivePhase(gameId, { phase: next });
};
const handleConcedeToggle = () => {
if (!hasLiveGame || (!canConcede && !canUnconcede)) {
return;
}
if (isConceded) {
onRequestUnconcede();
} else {
onRequestConcede();
}
};
const handleRemoveArrows = () => {
if (!canRemoveArrows) {
return;
}
for (const arrowId of localArrowIds) {
webClient.request.game.deleteArrow(gameId, { arrowId });
}
};
const handleLeave = () => {
if (!canLeave || !hasLiveGame) {
return;
}
webClient.request.game.leaveGame(gameId);
};
const handleToggleInvert = () => {
if (settingsStatus !== LoadingState.READY) {
return;
}
void updateSettings({ invertVerticalCoordinate: !invertVerticalCoordinate });
};
const handleKick = (playerId: number) => {
if (!hasLiveGame) {
return;
}
webClient.request.game.kickFromGame(gameId, { playerId });
setKickAnchor(null);
};
return {
isHost,
isConceded,
invertVerticalCoordinate,
settingsReady: settingsStatus === LoadingState.READY,
canAdvance,
canLeave,
canConcede,
canUnconcede,
canRoll,
canKick,
canRemoveArrows,
hasLiveGame,
opponents,
kickAnchor,
setKickAnchor,
handlePassTurn,
handleReverseTurn,
handleNextPhase,
handleConcedeToggle,
handleRemoveArrows,
handleLeave,
handleToggleInvert,
handleKick,
};
}

View file

@ -3,10 +3,10 @@ import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import Check from '@mui/icons-material/Check';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
import { useZoneContextMenu } from './useZoneContextMenu';
import './ZoneContextMenu.css';
export interface ZoneContextMenuProps {
@ -22,89 +22,52 @@ export interface ZoneContextMenuProps {
onRequestRevealZone: () => void;
}
function ZoneContextMenu({
isOpen,
anchorPosition,
gameId,
playerId,
zoneName,
onClose,
onRequestDrawN,
onRequestDumpN,
onRequestRevealTopN,
onRequestRevealZone,
}: ZoneContextMenuProps) {
const webClient = useWebClient();
function ZoneContextMenu(props: ZoneContextMenuProps) {
const {
isOpen,
anchorPosition,
zoneName,
onClose,
onRequestDrawN,
onRequestDumpN,
onRequestRevealTopN,
onRequestRevealZone,
} = props;
const {
ready,
alwaysReveal,
alwaysLook,
handleDrawOne,
handleShuffle,
handleRevealTop,
handleToggleAlwaysReveal,
handleToggleAlwaysLook,
runAndClose,
} = useZoneContextMenu(props);
const zone = useAppSelector((state) =>
playerId != null && zoneName != null
? GameSelectors.getZone(state, gameId, playerId, zoneName)
: undefined,
);
if (playerId == null || zoneName == null) {
if (!ready) {
return null;
}
const game = webClient.request.game;
const alwaysReveal = zone?.alwaysRevealTopCard ?? false;
const alwaysLook = zone?.alwaysLookAtTopCard ?? false;
// Close-then-act helpers (avoid duplicating onClose at every site).
const run = (fn: () => void) => () => {
fn();
onClose();
};
const handleDrawOne = () => {
game.drawCards(gameId, { number: 1 });
};
const handleShuffle = () => {
game.shuffle(gameId, { zoneName: App.ZoneName.DECK, start: 0, end: -1 });
};
const handleRevealTop = () => {
game.revealCards(gameId, {
zoneName: App.ZoneName.DECK,
playerId: -1,
topCards: 1,
});
};
const handleToggleAlwaysReveal = () => {
game.changeZoneProperties(gameId, {
zoneName: App.ZoneName.DECK,
alwaysRevealTopCard: !alwaysReveal,
});
};
const handleToggleAlwaysLook = () => {
game.changeZoneProperties(gameId, {
zoneName: App.ZoneName.DECK,
alwaysLookAtTopCard: !alwaysLook,
});
};
const menuItems: React.ReactNode[] = [];
if (zoneName === App.ZoneName.DECK) {
menuItems.push(
<MenuItem key="draw-one" onClick={run(handleDrawOne)}>Draw a card</MenuItem>,
<MenuItem key="draw-n" onClick={run(onRequestDrawN)}>Draw N cards</MenuItem>,
<MenuItem key="shuffle" onClick={run(handleShuffle)}>Shuffle</MenuItem>,
<MenuItem key="dump-n" onClick={run(onRequestDumpN)}>Dump top N</MenuItem>,
<MenuItem key="draw-one" onClick={runAndClose(handleDrawOne)}>Draw a card</MenuItem>,
<MenuItem key="draw-n" onClick={runAndClose(onRequestDrawN)}>Draw N cards</MenuItem>,
<MenuItem key="shuffle" onClick={runAndClose(handleShuffle)}>Shuffle</MenuItem>,
<MenuItem key="dump-n" onClick={runAndClose(onRequestDumpN)}>Dump top N</MenuItem>,
<Divider key="d1" />,
<MenuItem key="reveal-top" onClick={run(handleRevealTop)}>
<MenuItem key="reveal-top" onClick={runAndClose(handleRevealTop)}>
Reveal top card to all
</MenuItem>,
<MenuItem key="reveal-top-n" onClick={run(onRequestRevealTopN)}>
<MenuItem key="reveal-top-n" onClick={runAndClose(onRequestRevealTopN)}>
Reveal top N to
</MenuItem>,
<Divider key="d2" />,
<MenuItem
key="always-reveal"
onClick={run(handleToggleAlwaysReveal)}
onClick={runAndClose(handleToggleAlwaysReveal)}
className="zone-context-menu__toggle"
>
<span className="zone-context-menu__check" aria-hidden>
@ -114,7 +77,7 @@ function ZoneContextMenu({
</MenuItem>,
<MenuItem
key="always-look"
onClick={run(handleToggleAlwaysLook)}
onClick={runAndClose(handleToggleAlwaysLook)}
className="zone-context-menu__toggle"
>
<span className="zone-context-menu__check" aria-hidden>
@ -125,13 +88,13 @@ function ZoneContextMenu({
);
} else if (zoneName === App.ZoneName.GRAVE) {
menuItems.push(
<MenuItem key="reveal-grave" onClick={run(onRequestRevealZone)}>
<MenuItem key="reveal-grave" onClick={runAndClose(onRequestRevealZone)}>
Reveal graveyard to
</MenuItem>,
);
} else if (zoneName === App.ZoneName.EXILE) {
menuItems.push(
<MenuItem key="reveal-exile" onClick={run(onRequestRevealZone)}>
<MenuItem key="reveal-exile" onClick={runAndClose(onRequestRevealZone)}>
Reveal exile to
</MenuItem>,
);

View file

@ -0,0 +1,89 @@
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
export interface ZoneContextMenu {
ready: boolean;
alwaysReveal: boolean;
alwaysLook: boolean;
handleDrawOne: () => void;
handleShuffle: () => void;
handleRevealTop: () => void;
handleToggleAlwaysReveal: () => void;
handleToggleAlwaysLook: () => void;
runAndClose: (fn: () => void) => () => void;
}
export interface UseZoneContextMenuArgs {
gameId: number;
playerId: number | null;
zoneName: string | null;
onClose: () => void;
}
export function useZoneContextMenu({
gameId,
playerId,
zoneName,
onClose,
}: UseZoneContextMenuArgs): ZoneContextMenu {
const webClient = useWebClient();
const zone = useAppSelector((state) =>
playerId != null && zoneName != null
? GameSelectors.getZone(state, gameId, playerId, zoneName)
: undefined,
);
const ready = playerId != null && zoneName != null;
const alwaysReveal = zone?.alwaysRevealTopCard ?? false;
const alwaysLook = zone?.alwaysLookAtTopCard ?? false;
// Close-then-act helpers (avoid duplicating onClose at every site).
const runAndClose = (fn: () => void) => () => {
fn();
onClose();
};
const handleDrawOne = () => {
webClient.request.game.drawCards(gameId, { number: 1 });
};
const handleShuffle = () => {
webClient.request.game.shuffle(gameId, { zoneName: App.ZoneName.DECK, start: 0, end: -1 });
};
const handleRevealTop = () => {
webClient.request.game.revealCards(gameId, {
zoneName: App.ZoneName.DECK,
playerId: -1,
topCards: 1,
});
};
const handleToggleAlwaysReveal = () => {
webClient.request.game.changeZoneProperties(gameId, {
zoneName: App.ZoneName.DECK,
alwaysRevealTopCard: !alwaysReveal,
});
};
const handleToggleAlwaysLook = () => {
webClient.request.game.changeZoneProperties(gameId, {
zoneName: App.ZoneName.DECK,
alwaysLookAtTopCard: !alwaysLook,
});
};
return {
ready,
alwaysReveal,
alwaysLook,
handleDrawOne,
handleShuffle,
handleRevealTop,
handleToggleAlwaysReveal,
handleToggleAlwaysLook,
runAndClose,
};
}

View file

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { styled } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import { Select, MenuItem } from '@mui/material';
@ -13,20 +12,13 @@ import AddIcon from '@mui/icons-material/Add';
import EditRoundedIcon from '@mui/icons-material/Edit';
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
import { KnownHostDialog } from '@app/dialogs';
import { getHostPort, HostDTO } from '@app/services';
import { ServerTypes } from '@app/store';
import { App } from '@app/types';
import Toast from '../Toast/Toast';
import './KnownHosts.css';
import { TestConnection, useKnownHostsComponent } from './useKnownHostsComponent';
enum TestConnection {
TESTING = 'testing',
FAILED = 'failed',
SUCCESS = 'success',
}
import './KnownHosts.css';
const PREFIX = 'KnownHosts';
@ -57,115 +49,31 @@ const Root = styled('div')(({ theme }) => ({
},
},
}));
const KnownHosts = (props: any) => {
const { input, meta, disabled } = props;
const onChange: (value: HostDTO) => void = input.onChange;
const { touched, error, warning } = meta;
const { t } = useTranslation();
const webClient = useWebClient();
const knownHosts = useKnownHosts();
const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({
open: false,
edit: null,
});
const [testingConnection, setTestingConnection] = useState<TestConnection | null>(null);
const [showCreateToast, setShowCreateToast] = useState(false);
const [showDeleteToast, setShowDeleteToast] = useState(false);
const [showEditToast, setShowEditToast] = useState(false);
const selectedHost =
knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined;
const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : [];
const testConnection = (host: HostDTO) => {
setTestingConnection(TestConnection.TESTING);
webClient.request.authentication.testConnection({ ...getHostPort(host) });
};
useEffect(() => {
if (!selectedHost) {
return;
}
onChange(selectedHost);
testConnection(selectedHost);
}, [selectedHost]);
useReduxEffect(
() => {
setTestingConnection(TestConnection.SUCCESS);
},
ServerTypes.TEST_CONNECTION_SUCCESSFUL,
[]
);
useReduxEffect(
() => {
setTestingConnection(TestConnection.FAILED);
},
ServerTypes.TEST_CONNECTION_FAILED,
[]
);
const onPick = async (host: HostDTO) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
onChange(host);
await knownHosts.select(host.id!);
testConnection(host);
};
const openAddKnownHostDialog = () => {
setDialogState((s) => ({ ...s, open: true, edit: null }));
};
const openEditKnownHostDialog = (host: HostDTO) => {
setDialogState((s) => ({ ...s, open: true, edit: host }));
};
const closeKnownHostDialog = () => {
setDialogState((s) => ({ ...s, open: false }));
};
const handleDialogRemove = async ({ id }: { id: number }) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
await knownHosts.remove(id);
closeKnownHostDialog();
setShowDeleteToast(true);
};
const handleDialogSubmit = async ({
id,
name,
host,
port,
}: {
id?: number;
name: string;
host: string;
port: string;
}) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
if (id) {
await knownHosts.update(id, { name, host, port });
setShowEditToast(true);
} else {
const newHost: App.Host = { name, host, port, editable: true };
await knownHosts.add(newHost);
setShowCreateToast(true);
}
closeKnownHostDialog();
};
const {
hosts,
selectedHost,
testingConnection,
dialogState,
showCreateToast,
showDeleteToast,
showEditToast,
setShowCreateToast,
setShowDeleteToast,
setShowEditToast,
onPick,
openAddKnownHostDialog,
openEditKnownHostDialog,
closeKnownHostDialog,
handleDialogRemove,
handleDialogSubmit,
} = useKnownHostsComponent({ onChange });
return (
<Root className={'KnownHosts ' + classes.root}>

View file

@ -0,0 +1,167 @@
import { useEffect, useState } from 'react';
import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
import { getHostPort, HostDTO } from '@app/services';
import { ServerTypes } from '@app/store';
import { App } from '@app/types';
export enum TestConnection {
TESTING = 'testing',
FAILED = 'failed',
SUCCESS = 'success',
}
export interface KnownHostsComponent {
hosts: App.Host[];
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>;
openAddKnownHostDialog: () => void;
openEditKnownHostDialog: (host: HostDTO) => void;
closeKnownHostDialog: () => void;
handleDialogRemove: (args: { id: number }) => Promise<void>;
handleDialogSubmit: (args: {
id?: number;
name: string;
host: string;
port: string;
}) => Promise<void>;
}
export interface UseKnownHostsComponentArgs {
onChange: (value: HostDTO) => void;
}
export function useKnownHostsComponent({
onChange,
}: UseKnownHostsComponentArgs): KnownHostsComponent {
const webClient = useWebClient();
const knownHosts = useKnownHosts();
const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({
open: false,
edit: null,
});
const [testingConnection, setTestingConnection] = useState<TestConnection | null>(null);
const [showCreateToast, setShowCreateToast] = useState(false);
const [showDeleteToast, setShowDeleteToast] = useState(false);
const [showEditToast, setShowEditToast] = useState(false);
const selectedHost =
knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined;
const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : [];
const testConnection = (host: HostDTO) => {
setTestingConnection(TestConnection.TESTING);
webClient.request.authentication.testConnection({ ...getHostPort(host) });
};
useEffect(() => {
if (!selectedHost) {
return;
}
onChange(selectedHost);
testConnection(selectedHost);
}, [selectedHost]);
useReduxEffect(
() => {
setTestingConnection(TestConnection.SUCCESS);
},
ServerTypes.TEST_CONNECTION_SUCCESSFUL,
[],
);
useReduxEffect(
() => {
setTestingConnection(TestConnection.FAILED);
},
ServerTypes.TEST_CONNECTION_FAILED,
[],
);
const onPick = async (host: HostDTO) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
onChange(host);
await knownHosts.select(host.id!);
testConnection(host);
};
const openAddKnownHostDialog = () => {
setDialogState((s) => ({ ...s, open: true, edit: null }));
};
const openEditKnownHostDialog = (host: HostDTO) => {
setDialogState((s) => ({ ...s, open: true, edit: host }));
};
const closeKnownHostDialog = () => {
setDialogState((s) => ({ ...s, open: false }));
};
const handleDialogRemove = async ({ id }: { id: number }) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
await knownHosts.remove(id);
closeKnownHostDialog();
setShowDeleteToast(true);
};
const handleDialogSubmit = async ({
id,
name,
host,
port,
}: {
id?: number;
name: string;
host: string;
port: string;
}) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
if (id) {
await knownHosts.update(id, { name, host, port });
setShowEditToast(true);
} else {
const newHost: App.Host = { name, host, port, editable: true };
await knownHosts.add(newHost);
setShowCreateToast(true);
}
closeKnownHostDialog();
};
return {
hosts,
selectedHost,
testingConnection,
dialogState,
showCreateToast,
showDeleteToast,
showEditToast,
setShowCreateToast,
setShowDeleteToast,
setShowEditToast,
onPick,
openAddKnownHostDialog,
openEditKnownHostDialog,
closeKnownHostDialog,
handleDialogRemove,
handleDialogSubmit,
};
}

View file

@ -1,13 +1,11 @@
import React, { useMemo, useState } from 'react';
import { styled } from '@mui/material/styles';
import Popover from '@mui/material/Popover';
import { CardDTO, TokenDTO } from '@app/services';
import CardDetails from '../CardDetails/CardDetails';
import TokenDetails from '../TokenDetails/TokenDetails';
import { useCardCallout } from './useCardCallout';
import './CardCallout.css';
const PREFIX = 'CardCallout';
@ -28,31 +26,8 @@ const Root = styled('span')(() => ({
}));
const CardCallout = ({ name }) => {
const [card, setCard] = useState<CardDTO>(null);
const [token, setToken] = useState<TokenDTO>(null);
const [anchorEl, setAnchorEl] = useState<Element>(null);
useMemo(async () => {
const card = await CardDTO.get(name);
if (card) {
return setCard(card)
}
const token = await TokenDTO.get(name);
if (token) {
return setToken(token);
}
}, [name]);
const handlePopoverOpen = (event) => {
setAnchorEl(event.currentTarget);
};
const handlePopoverClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
const { card, token, anchorEl, open, handlePopoverOpen, handlePopoverClose } =
useCardCallout(name);
return (
<Root className='callout'>
@ -81,8 +56,8 @@ const CardCallout = ({ name }) => {
}}
>
<div className="callout-card">
{ card && (<CardDetails card={card} />) }
{ token && (<TokenDetails token={token} />) }
{card && (<CardDetails card={card} />)}
{token && (<TokenDetails token={token} />)}
</div>
</Popover>
)

View file

@ -1,11 +1,10 @@
import React, { useEffect, useState } from 'react';
import { NavLink, generatePath } from 'react-router-dom';
import type { ReactNode } from 'react';
import { App } from '@app/types';
import CardCallout from './CardCallout';
import { useParsedMessage } from './useMessage';
import './Message.css';
const Message = ({ message: { message } }) => (
@ -17,23 +16,12 @@ const Message = ({ message: { message } }) => (
);
const ParsedMessage = ({ message }) => {
const [messageChunks, setMessageChunks] = useState(null);
const [name, setName] = useState(null);
useEffect(() => {
const name = message.match(App.MESSAGE_SENDER_REGEX);
if (name) {
setName(name[1]);
}
setMessageChunks(parseMessage(message));
}, [message]);
const { name, chunks } = useParsedMessage(message, parseChunks);
return (
<div>
{ name && (<strong><PlayerLink name={name} />:</strong>) }
{ messageChunks }
{name && (<strong><PlayerLink name={name} />:</strong>)}
{chunks}
</div>
);
};
@ -44,14 +32,7 @@ const PlayerLink = ({ name, label = name }) => (
</NavLink>
);
function parseMessage(message) {
return message.replace(App.MESSAGE_SENDER_REGEX, '')
.split(App.CARD_CALLOUT_REGEX)
.filter(chunk => !!chunk)
.map(parseChunks);
}
function parseChunks(chunk, index) {
function parseChunks(chunk: string, index: number): ReactNode {
if (chunk.match(App.CARD_CALLOUT_REGEX)) {
const name = chunk.replace(App.CALLOUT_BOUNDARY_REGEX, '').trim();
return (<CardCallout name={name} key={index}></CardCallout>);
@ -68,9 +49,9 @@ function parseChunks(chunk, index) {
return chunk;
}
function parseUrlChunk(chunk) {
function parseUrlChunk(chunk: string): ReactNode {
return chunk.split(App.URL_REGEX)
.filter(urlChunk => !!urlChunk)
.filter((urlChunk) => !!urlChunk)
.map((urlChunk, index) => {
if (urlChunk.match(App.URL_REGEX)) {
return (<a className='link' href={urlChunk} key={index} target='_blank' rel='noopener noreferrer'>{urlChunk}</a>);
@ -80,9 +61,9 @@ function parseUrlChunk(chunk) {
});
}
function parseMentionChunk(chunk) {
function parseMentionChunk(chunk: string): ReactNode {
return chunk.split(App.MENTION_REGEX)
.filter(mentionChunk => !!mentionChunk)
.filter((mentionChunk) => !!mentionChunk)
.map((mentionChunk, index) => {
const mention = mentionChunk.match(App.MENTION_REGEX);

View file

@ -0,0 +1,42 @@
import { useMemo, useState } from 'react';
import { CardDTO, TokenDTO } from '@app/services';
export interface CardCallout {
card: CardDTO | null;
token: TokenDTO | null;
anchorEl: Element | null;
open: boolean;
handlePopoverOpen: (event: React.MouseEvent) => void;
handlePopoverClose: () => void;
}
export function useCardCallout(name: string): CardCallout {
const [card, setCard] = useState<CardDTO | null>(null);
const [token, setToken] = useState<TokenDTO | null>(null);
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
useMemo(async () => {
const c = await CardDTO.get(name);
if (c) {
return setCard(c);
}
const t = await TokenDTO.get(name);
if (t) {
return setToken(t);
}
}, [name]);
const handlePopoverOpen = (event: React.MouseEvent) => {
setAnchorEl(event.currentTarget);
};
const handlePopoverClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
return { card, token, anchorEl, open, handlePopoverOpen, handlePopoverClose };
}

View file

@ -0,0 +1,33 @@
import { useEffect, useState, type ReactNode } from 'react';
import { App } from '@app/types';
export interface ParsedMessage {
name: string | null;
chunks: ReactNode[] | null;
}
export type ChunkParser = (chunk: string, index: number) => ReactNode;
export function useParsedMessage(message: string, parseChunk: ChunkParser): ParsedMessage {
const [chunks, setChunks] = useState<ReactNode[] | null>(null);
const [name, setName] = useState<string | null>(null);
useEffect(() => {
const match = message.match(App.MESSAGE_SENDER_REGEX);
if (match) {
setName(match[1]);
}
setChunks(parseMessage(message, parseChunk));
}, [message, parseChunk]);
return { name, chunks };
}
export function parseMessage(message: string, parseChunk: ChunkParser): ReactNode[] {
return message
.replace(App.MESSAGE_SENDER_REGEX, '')
.split(App.CARD_CALLOUT_REGEX)
.filter((chunk) => !!chunk)
.map(parseChunk);
}

View file

@ -1,53 +1,32 @@
import React, { useState } from 'react';
import { NavLink, generatePath } from 'react-router-dom';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { Images } from '@app/images';
import { useWebClient } from '@app/hooks';
import { ServerSelectors } from '@app/store';
import { App, Data } from '@app/types';
import { useAppSelector } from '@app/store';
import { useUserDisplay } from './useUserDisplay';
import './UserDisplay.css';
interface UserDisplayProps {
user: Data.ServerInfo_User;
}
const UserDisplay = ({ user }: UserDisplayProps) => {
const buddyList = useAppSelector(state => ServerSelectors.getBuddyList(state));
const ignoreList = useAppSelector(state => ServerSelectors.getIgnoreList(state));
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const webClient = useWebClient();
const { name, country } = user;
const handleClick = (event) => {
event.preventDefault();
setPosition({ x: event.clientX + 2, y: event.clientY + 4 });
};
const handleClose = () => setPosition(null);
const isABuddy = Boolean(buddyList[user.name]);
const isIgnored = Boolean(ignoreList[user.name]);
const onAddBuddy = () => {
webClient.request.session.addToBuddyList(user.name);
handleClose();
};
const onRemoveBuddy = () => {
webClient.request.session.removeFromBuddyList(user.name);
handleClose();
};
const onAddIgnore = () => {
webClient.request.session.addToIgnoreList(user.name);
handleClose();
};
const onRemoveIgnore = () => {
webClient.request.session.removeFromIgnoreList(user.name);
handleClose();
};
const {
position,
isABuddy,
isIgnored,
handleClick,
handleClose,
onAddBuddy,
onRemoveBuddy,
onAddIgnore,
onRemoveIgnore,
} = useUserDisplay(name);
return (
<div className="user-display">
@ -87,8 +66,4 @@ const UserDisplay = ({ user }: UserDisplayProps) => {
);
};
interface UserDisplayProps {
user: Data.ServerInfo_User;
}
export default UserDisplay;

View file

@ -0,0 +1,62 @@
import { useState } from 'react';
import { useWebClient } from '@app/hooks';
import { ServerSelectors, useAppSelector } from '@app/store';
export interface UserDisplay {
position: { x: number; y: number } | null;
isABuddy: boolean;
isIgnored: boolean;
handleClick: (event: React.MouseEvent) => void;
handleClose: () => void;
onAddBuddy: () => void;
onRemoveBuddy: () => void;
onAddIgnore: () => void;
onRemoveIgnore: () => void;
}
export function useUserDisplay(userName: string): UserDisplay {
const buddyList = useAppSelector((state) => ServerSelectors.getBuddyList(state));
const ignoreList = useAppSelector((state) => ServerSelectors.getIgnoreList(state));
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const webClient = useWebClient();
const handleClick = (event: React.MouseEvent) => {
event.preventDefault();
setPosition({ x: event.clientX + 2, y: event.clientY + 4 });
};
const handleClose = () => setPosition(null);
const isABuddy = Boolean(buddyList[userName]);
const isIgnored = Boolean(ignoreList[userName]);
const onAddBuddy = () => {
webClient.request.session.addToBuddyList(userName);
handleClose();
};
const onRemoveBuddy = () => {
webClient.request.session.removeFromBuddyList(userName);
handleClose();
};
const onAddIgnore = () => {
webClient.request.session.addToIgnoreList(userName);
handleClose();
};
const onRemoveIgnore = () => {
webClient.request.session.removeFromIgnoreList(userName);
handleClose();
};
return {
position,
isABuddy,
isIgnored,
handleClick,
handleClose,
onAddBuddy,
onRemoveBuddy,
onAddIgnore,
onRemoveIgnore,
};
}

View file

@ -1,4 +1,3 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
@ -6,49 +5,28 @@ import ListItemButton from '@mui/material/ListItemButton';
import Paper from '@mui/material/Paper';
import { UserDisplay, VirtualList, AuthGuard, LanguageDropdown } from '@app/components';
import { useWebClient } from '@app/hooks';
import { ServerSelectors } from '@app/store';
import Layout from '../Layout/Layout';
import { useAppSelector } from '@app/store';
import AddToBuddies from './AddToBuddies';
import AddToIgnore from './AddToIgnore';
import { useAccount } from './useAccount';
import './Account.css';
const Account = () => {
const buddyList = useAppSelector(state => ServerSelectors.getSortedBuddyList(state));
const ignoreList = useAppSelector(state => ServerSelectors.getSortedIgnoreList(state));
const serverName = useAppSelector(state => ServerSelectors.getName(state));
const serverVersion = useAppSelector(state => ServerSelectors.getVersion(state));
const user = useAppSelector(state => ServerSelectors.getUser(state));
const webClient = useWebClient();
const { country, realName, name, userLevel, accountageSecs, avatarBmp } = user || {};
const avatarUrl = useMemo(() => {
if (!avatarBmp) {
return '';
}
return URL.createObjectURL(new Blob([avatarBmp as BlobPart], { type: 'image/png' }));
}, [avatarBmp]);
useEffect(() => {
return () => {
if (avatarUrl) {
URL.revokeObjectURL(avatarUrl);
}
};
}, [avatarUrl]);
const { t } = useTranslation();
const handleAddToBuddies = ({ userName }) => {
webClient.request.session.addToBuddyList(userName);
};
const handleAddToIgnore = ({ userName }) => {
webClient.request.session.addToIgnoreList(userName);
};
const {
buddyList,
ignoreList,
serverName,
serverVersion,
user,
avatarUrl,
handleAddToBuddies,
handleAddToIgnore,
handleDisconnect,
} = useAccount();
const { country, realName, name, userLevel, accountageSecs } = user || {};
return (
<Layout className="account">
@ -59,11 +37,11 @@ const Account = () => {
Buddies Online: ?/{buddyList.length}
</div>
<VirtualList
items={ buddyList.map(user => (
items={buddyList.map(user => (
<ListItemButton key={user.name} dense>
<UserDisplay user={user} />
</ListItemButton>
)) }
))}
/>
<div style={{ borderTop: '1px solid' }}>
<AddToBuddies onSubmit={handleAddToBuddies} />
@ -76,11 +54,11 @@ const Account = () => {
Ignored Users Online: ?/{ignoreList.length}
</div>
<VirtualList
items={ ignoreList.map(user => (
items={ignoreList.map(user => (
<ListItemButton key={user.name} dense>
<UserDisplay user={user} />
</ListItemButton>
)) }
))}
/>
<div style={{ borderTop: '1px solid' }}>
<AddToIgnore onSubmit={handleAddToIgnore} />
@ -89,7 +67,7 @@ const Account = () => {
</div>
<div className="account-column overflow-scroll">
<Paper className="account-details" style={{ margin: '0 0 5px 0' }}>
{ avatarUrl && <img src={avatarUrl} alt={name} /> }
{avatarUrl && <img src={avatarUrl} alt={name} />}
<p><strong>{name}</strong></p>
<p>Location: ({country?.toUpperCase()})</p>
<p>User Level: {userLevel}</p>
@ -105,12 +83,8 @@ const Account = () => {
<Paper className="account-details">
<p>Server Name: {serverName}</p>
<p>Server Version: {serverVersion}</p>
<Button
color="primary"
variant="contained"
onClick={() => webClient.request.authentication.disconnect()}
>
{ t('Common.disconnect') }
<Button color="primary" variant="contained" onClick={handleDisconnect}>
{t('Common.disconnect')}
</Button>
<div className="account-details__lang">
@ -119,7 +93,7 @@ const Account = () => {
</Paper>
</div>
</Layout>
)
}
);
};
export default Account;

View file

@ -0,0 +1,65 @@
import { useEffect, useMemo } from 'react';
import { useWebClient } from '@app/hooks';
import { ServerSelectors, useAppSelector } from '@app/store';
export interface Account {
buddyList: any[];
ignoreList: any[];
serverName: string | undefined;
serverVersion: string | undefined;
user: any;
avatarUrl: string;
handleAddToBuddies: (args: { userName: string }) => void;
handleAddToIgnore: (args: { userName: string }) => void;
handleDisconnect: () => void;
}
export function useAccount(): Account {
const buddyList = useAppSelector((state) => ServerSelectors.getSortedBuddyList(state));
const ignoreList = useAppSelector((state) => ServerSelectors.getSortedIgnoreList(state));
const serverName = useAppSelector((state) => ServerSelectors.getName(state));
const serverVersion = useAppSelector((state) => ServerSelectors.getVersion(state));
const user = useAppSelector((state) => ServerSelectors.getUser(state));
const webClient = useWebClient();
const { avatarBmp } = user || {};
const avatarUrl = useMemo(() => {
if (!avatarBmp) {
return '';
}
return URL.createObjectURL(new Blob([avatarBmp as BlobPart], { type: 'image/png' }));
}, [avatarBmp]);
useEffect(() => {
return () => {
if (avatarUrl) {
URL.revokeObjectURL(avatarUrl);
}
};
}, [avatarUrl]);
const handleAddToBuddies = ({ userName }: { userName: string }) => {
webClient.request.session.addToBuddyList(userName);
};
const handleAddToIgnore = ({ userName }: { userName: string }) => {
webClient.request.session.addToIgnoreList(userName);
};
const handleDisconnect = () => {
webClient.request.authentication.disconnect();
};
return {
buddyList,
ignoreList,
serverName,
serverVersion,
user,
avatarUrl,
handleAddToBuddies,
handleAddToIgnore,
handleDisconnect,
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,91 @@
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
import { KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { createCardRegistry, type CardRegistry } from '@app/components';
import { useCurrentGame, useGameAccess, type CurrentGame, type GameAccess } from '@app/hooks';
import type { Data } from '@app/types';
import { useGameArrowInteractions, type GameArrowInteractions } from './useGameArrowInteractions';
import { useGameDialogs, type GameDialogs } from './useGameDialogs';
import { useGameDnd, type GameDnd } from './useGameDnd';
import { useGameLifecycleNavigation } from './useGameLifecycleNavigation';
import { useGameOpponentSelector, type GameOpponentSelector } from './useGameOpponentSelector';
export interface Game extends CurrentGame {
boardRef: RefObject<HTMLDivElement>;
cardRegistry: CardRegistry;
sensors: ReturnType<typeof useSensors>;
hoveredCard: Data.ServerInfo_Card | null;
setHoveredCard: (card: Data.ServerInfo_Card | null) => void;
isRotated: boolean;
toggleRotated: () => void;
localAccess: GameAccess;
opponentAccess: GameAccess;
deckSelectOpen: boolean;
opponents: GameOpponentSelector;
arrows: GameArrowInteractions;
dialogs: GameDialogs;
dnd: GameDnd;
}
export function useGame(): Game {
const current = useCurrentGame();
const { gameId, game, localPlayer, isSpectator } = current;
useGameLifecycleNavigation(gameId);
const boardRef = useRef<HTMLDivElement>(null);
const cardRegistry = useMemo(() => createCardRegistry(), []);
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor));
const [hoveredCard, setHoveredCard] = useState<Data.ServerInfo_Card | null>(null);
// View-only 90° rotation; local to this tab, mirrors desktop's
// Player::actRotateLocal which applies a QGraphicsView transform with no
// server call.
const [isRotated, setIsRotated] = useState(false);
const toggleRotated = useCallback(() => setIsRotated((prev) => !prev), []);
const opponents = useGameOpponentSelector(game);
const localAccess = useGameAccess(gameId, game?.localPlayerId);
const opponentAccess = useGameAccess(gameId, opponents.shownOpponentId);
const arrows = useGameArrowInteractions({ gameId, game, boardRef, cardRegistry });
const dialogs = useGameDialogs({
gameId,
game,
localPlayer,
localAccess,
isSpectator,
startPendingArrow: arrows.startPendingArrow,
startPendingAttach: arrows.startPendingAttach,
});
const dnd = useGameDnd({ gameId, onDragStart: arrows.cancelPendingOnDragStart });
// Explicit localPlayer null-check closes a window during reconnect where
// `game` is present but `players[localPlayerId]` is not yet populated
// (Event_GameStateChanged arrives after Event_GameJoined echo).
const deckSelectOpen =
game != null &&
localPlayer != null &&
!game.started &&
!current.isSpectator &&
!current.isJudge &&
!localPlayer.properties.readyStart;
return {
...current,
boardRef,
cardRegistry,
sensors,
hoveredCard,
setHoveredCard,
isRotated,
toggleRotated,
localAccess,
opponentAccess,
deckSelectOpen,
opponents,
arrows,
dialogs,
dnd,
};
}

View file

@ -0,0 +1,213 @@
import { createRef } from 'react';
import { act, renderHook } from '@testing-library/react';
import { createCardRegistry } from '../../components/Game/CardRegistry/CardRegistryContext';
import { combineReducers } from '@reduxjs/toolkit';
import { gamesReducer } from '../../store/game/game.reducer';
import { makeGameEntry, makePlayerEntry, makePlayerProperties } from '../../store/game/__mocks__/fixtures';
import type { GamesState } from '../../store/game/game.interfaces';
import { makeReduxWebClientHookWrapper } from '../../__test-utils__/makeHookWrapper';
import { App } from '../../types';
import { useGameArrowInteractions } from './useGameArrowInteractions';
function setup({ localPlayerId = 1 }: { localPlayerId?: number } = {}) {
const game = makeGameEntry({
localPlayerId,
players: {
[localPlayerId]: makePlayerEntry({
properties: makePlayerProperties({ playerId: localPlayerId }),
}),
},
});
const gamesState: GamesState = { games: { 1: { ...game, info: { ...game.info, gameId: 1 } } } };
const { Wrapper, webClient } = makeReduxWebClientHookWrapper({
reducer: combineReducers({ games: gamesReducer }),
preloadedState: { games: gamesState },
});
const boardRef = createRef<HTMLDivElement>();
const board = document.createElement('div');
board.getBoundingClientRect = () =>
({ left: 0, top: 0, right: 1000, bottom: 1000, width: 1000, height: 1000, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
(boardRef as { current: HTMLDivElement | null }).current = board;
const cardRegistry = createCardRegistry();
const { result } = renderHook(
() =>
useGameArrowInteractions({
gameId: 1,
game: { ...game, info: { ...game.info, gameId: 1 } },
boardRef,
cardRegistry,
}),
{ wrapper: Wrapper },
);
return { result, webClient, boardRef };
}
function makeCardElement({
playerId,
zone,
cardId,
}: {
playerId: number;
zone: string;
cardId: number;
}): HTMLElement {
const el = document.createElement('div');
el.setAttribute('data-card-id', String(cardId));
el.setAttribute('data-card-owner', String(playerId));
el.setAttribute('data-card-zone', zone);
document.body.appendChild(el);
return el;
}
function fireMouseEvent(type: string, init: Partial<MouseEventInit> = {}) {
window.dispatchEvent(new MouseEvent(type, { bubbles: true, ...init }));
}
describe('useGameArrowInteractions', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('creates an arrow after right-click-drag past the 8px threshold', () => {
const { result, webClient } = setup();
const targetEl = makeCardElement({ playerId: 2, zone: App.ZoneName.TABLE, cardId: 99 });
const origElementFromPoint = document.elementFromPoint;
document.elementFromPoint = () => targetEl;
act(() => {
result.current.handleBoardMouseDown({
button: 2,
clientX: 10,
clientY: 10,
target: makeCardElement({ playerId: 1, zone: App.ZoneName.TABLE, cardId: 5 }),
} as unknown as React.MouseEvent<HTMLDivElement>);
});
act(() => {
fireMouseEvent('mousemove', { clientX: 30, clientY: 30 });
});
act(() => {
fireMouseEvent('mouseup', { button: 2, clientX: 30, clientY: 30 });
});
expect(webClient.request.game.createArrow).toHaveBeenCalledWith(
1,
expect.objectContaining({
startPlayerId: 1,
startCardId: 5,
targetPlayerId: 2,
targetCardId: 99,
targetZone: App.ZoneName.TABLE,
}),
);
document.elementFromPoint = origElementFromPoint;
});
it('plays the card (moveCard) when dragging from HAND to a non-HAND target', () => {
const { result, webClient } = setup({ localPlayerId: 1 });
const targetEl = makeCardElement({ playerId: 2, zone: App.ZoneName.TABLE, cardId: 99 });
const origElementFromPoint = document.elementFromPoint;
document.elementFromPoint = () => targetEl;
act(() => {
result.current.handleBoardMouseDown({
button: 2,
clientX: 0,
clientY: 0,
target: makeCardElement({ playerId: 1, zone: App.ZoneName.HAND, cardId: 5 }),
} as unknown as React.MouseEvent<HTMLDivElement>);
});
act(() => fireMouseEvent('mousemove', { clientX: 30, clientY: 30 }));
act(() => fireMouseEvent('mouseup', { button: 2, clientX: 30, clientY: 30 }));
expect(webClient.request.game.moveCard).toHaveBeenCalled();
expect(webClient.request.game.createArrow).not.toHaveBeenCalled();
document.elementFromPoint = origElementFromPoint;
});
it('does not send a request when the drop lands on the same card (cancel)', () => {
const { result, webClient } = setup();
const sameEl = makeCardElement({ playerId: 1, zone: App.ZoneName.TABLE, cardId: 5 });
const origElementFromPoint = document.elementFromPoint;
document.elementFromPoint = () => sameEl;
act(() => {
result.current.handleBoardMouseDown({
button: 2,
clientX: 0,
clientY: 0,
target: sameEl,
} as unknown as React.MouseEvent<HTMLDivElement>);
});
act(() => fireMouseEvent('mousemove', { clientX: 30, clientY: 30 }));
act(() => fireMouseEvent('mouseup', { button: 2, clientX: 30, clientY: 30 }));
expect(webClient.request.game.createArrow).not.toHaveBeenCalled();
document.elementFromPoint = origElementFromPoint;
});
it('does not send a request when mouseup is below the drag threshold', () => {
const { result, webClient } = setup();
const targetEl = makeCardElement({ playerId: 2, zone: App.ZoneName.TABLE, cardId: 99 });
const origElementFromPoint = document.elementFromPoint;
document.elementFromPoint = () => targetEl;
act(() => {
result.current.handleBoardMouseDown({
button: 2,
clientX: 10,
clientY: 10,
target: makeCardElement({ playerId: 1, zone: App.ZoneName.TABLE, cardId: 5 }),
} as unknown as React.MouseEvent<HTMLDivElement>);
});
act(() => fireMouseEvent('mouseup', { button: 2, clientX: 12, clientY: 12 }));
expect(webClient.request.game.createArrow).not.toHaveBeenCalled();
expect(webClient.request.game.moveCard).not.toHaveBeenCalled();
document.elementFromPoint = origElementFromPoint;
});
it('ESC cancels pending arrow state', () => {
const { result } = setup();
act(() => {
result.current.startPendingArrow({ sourcePlayerId: 1, sourceZone: App.ZoneName.TABLE, sourceCardId: 5 });
});
expect(result.current.arrowSourceKey).not.toBeNull();
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
});
expect(result.current.arrowSourceKey).toBeNull();
});
it('ESC does not cancel while a MUI dialog is open', () => {
const { result } = setup();
const dialog = document.createElement('div');
dialog.className = 'MuiDialog-root';
dialog.setAttribute('role', 'dialog');
document.body.appendChild(dialog);
act(() => {
result.current.startPendingArrow({ sourcePlayerId: 1, sourceZone: App.ZoneName.TABLE, sourceCardId: 5 });
});
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
});
expect(result.current.arrowSourceKey).not.toBeNull();
});
});

View file

@ -0,0 +1,404 @@
import { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { CardRegistry } from '@app/components';
import { makeCardKey } from '@app/components';
import { useWebClient } from '@app/hooks';
import { App, Data, type Enriched } from '@app/types';
interface PendingArrow {
sourcePlayerId: number;
sourceZone: string;
sourceCardId: number;
}
// Shares shape with PendingArrow today, but kept distinct so future
// protocol fields (e.g. desktop's attach-target coord hints) can diverge
// without a runtime switch.
interface PendingAttach {
sourcePlayerId: number;
sourceZone: string;
sourceCardId: number;
}
interface ArrowDragState {
sourcePlayerId: number;
sourceZone: string;
sourceCardId: number;
startX: number;
startY: number;
currentX: number;
currentY: number;
moved: boolean;
}
export interface ArrowDragPreview {
x1: number;
y1: number;
x2: number;
y2: number;
color: string;
}
export interface GameArrowInteractions {
arrowSourceKey: string | null;
dragPreview: ArrowDragPreview | null;
handleBoardMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
handleCardClick: (
ownerPlayerId: number,
zone: string,
card: Data.ServerInfo_Card,
) => void;
handleCardDoubleClick: (sourceZone: string, card: Data.ServerInfo_Card) => void;
startPendingArrow: (source: PendingArrow) => void;
startPendingAttach: (source: PendingAttach) => void;
cancelPendingOnDragStart: () => void;
}
export interface UseGameArrowInteractionsArgs {
gameId: number | undefined;
game: Enriched.GameEntry | undefined;
boardRef: RefObject<HTMLDivElement>;
cardRegistry: CardRegistry;
}
function arrowColorForModifiers(e: {
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
}): App.ColorRGBA {
if (e.ctrlKey) {
return App.ArrowColor.YELLOW;
}
if (e.altKey) {
return App.ArrowColor.BLUE;
}
if (e.shiftKey) {
return App.ArrowColor.GREEN;
}
return App.ArrowColor.RED;
}
const ARROW_DRAG_THRESHOLD_PX = 8;
export function useGameArrowInteractions({
gameId,
game,
boardRef,
cardRegistry,
}: UseGameArrowInteractionsArgs): GameArrowInteractions {
const webClient = useWebClient();
const [pendingArrow, setPendingArrow] = useState<PendingArrow | null>(null);
const [pendingAttach, setPendingAttach] = useState<PendingAttach | null>(null);
const [arrowDrag, setArrowDrag] = useState<ArrowDragState | null>(null);
const suppressNextContextMenuRef = useRef(false);
// ESC cancels a pending arrow OR attach (matches desktop). Suppress the
// cancel when a MUI dialog is open — the dialog's own ESC handler should
// win so the user isn't rug-pulled out of a modal form.
useEffect(() => {
if (!pendingArrow && !pendingAttach && !arrowDrag) {
return undefined;
}
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') {
return;
}
if (document.querySelector('.MuiDialog-root[role="dialog"]')) {
return;
}
setPendingArrow(null);
setPendingAttach(null);
setArrowDrag(null);
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [pendingArrow, pendingAttach, arrowDrag]);
// Right-click-drag arrow-draw lifecycle: window-level mousemove + mouseup
// listeners that track the cursor and finalize on release.
useEffect(() => {
if (!arrowDrag) {
return undefined;
}
const handleMove = (e: MouseEvent) => {
setArrowDrag((prev) => {
if (!prev) {
return prev;
}
const movedX = Math.abs(e.clientX - prev.startX);
const movedY = Math.abs(e.clientY - prev.startY);
const moved = prev.moved || movedX + movedY > ARROW_DRAG_THRESHOLD_PX;
return { ...prev, currentX: e.clientX, currentY: e.clientY, moved };
});
};
const handleUp = (e: MouseEvent) => {
if (e.button !== 2) {
return;
}
const drag = arrowDrag;
if (!drag) {
return;
}
const movedX = Math.abs(e.clientX - drag.startX);
const movedY = Math.abs(e.clientY - drag.startY);
const moved = drag.moved || movedX + movedY > ARROW_DRAG_THRESHOLD_PX;
setArrowDrag(null);
if (!moved || gameId == null) {
// Short right-click with no drag: let the contextmenu handler run
// (it will open the card menu).
return;
}
// Any real drag suppresses the contextmenu event that follows mouseup.
suppressNextContextMenuRef.current = true;
const el = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-card-id]') as HTMLElement | null;
if (!el) {
return;
}
const targetPlayerId = Number(el.getAttribute('data-card-owner'));
const targetZone = el.getAttribute('data-card-zone') ?? '';
const targetCardId = Number(el.getAttribute('data-card-id'));
if (!Number.isFinite(targetPlayerId) || !targetZone || !Number.isFinite(targetCardId)) {
return;
}
// Same-card drops are cancellations.
if (
targetPlayerId === drag.sourcePlayerId &&
targetZone === drag.sourceZone &&
targetCardId === drag.sourceCardId
) {
return;
}
// Desktop parity: dragging an arrow from a local-hand card to a target
// outside the hand auto-plays the card (card_item.cpp:243-250) — the
// card is moved to the battlefield before any arrow is drawn. The
// server re-keys the card id during the move, so we can't also send
// createArrow here; instead we resolve this drag as a play-card intent.
if (
drag.sourceZone === App.ZoneName.HAND &&
drag.sourcePlayerId === game?.localPlayerId &&
targetZone !== App.ZoneName.HAND
) {
webClient.request.game.moveCard(gameId, {
startPlayerId: drag.sourcePlayerId,
startZone: drag.sourceZone,
cardsToMove: { card: [{ cardId: drag.sourceCardId }] },
targetPlayerId: drag.sourcePlayerId,
targetZone: App.ZoneName.TABLE,
x: 0,
y: 0,
isReversed: false,
});
return;
}
webClient.request.game.createArrow(gameId, {
startPlayerId: drag.sourcePlayerId,
startZone: drag.sourceZone,
startCardId: drag.sourceCardId,
targetPlayerId,
targetZone,
targetCardId,
arrowColor: arrowColorForModifiers(e),
});
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
return () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleUp);
};
}, [arrowDrag, gameId, webClient, game?.localPlayerId]);
// Suppress the browser contextmenu event after a right-drag.
useEffect(() => {
const handler = (e: MouseEvent) => {
if (suppressNextContextMenuRef.current) {
e.preventDefault();
suppressNextContextMenuRef.current = false;
}
};
window.addEventListener('contextmenu', handler);
return () => window.removeEventListener('contextmenu', handler);
}, []);
const handleBoardMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (e.button !== 2) {
return;
}
const el = (e.target as HTMLElement).closest('[data-card-id]') as HTMLElement | null;
if (!el) {
return;
}
const sourcePlayerId = Number(el.getAttribute('data-card-owner'));
const sourceZone = el.getAttribute('data-card-zone') ?? '';
const sourceCardId = Number(el.getAttribute('data-card-id'));
if (!Number.isFinite(sourcePlayerId) || !sourceZone || !Number.isFinite(sourceCardId)) {
return;
}
setArrowDrag({
sourcePlayerId,
sourceZone,
sourceCardId,
startX: e.clientX,
startY: e.clientY,
currentX: e.clientX,
currentY: e.clientY,
moved: false,
});
}, []);
const arrowSourceKey = pendingArrow
? makeCardKey(pendingArrow.sourcePlayerId, pendingArrow.sourceZone, pendingArrow.sourceCardId)
: pendingAttach
? makeCardKey(pendingAttach.sourcePlayerId, pendingAttach.sourceZone, pendingAttach.sourceCardId)
: arrowDrag
? makeCardKey(arrowDrag.sourcePlayerId, arrowDrag.sourceZone, arrowDrag.sourceCardId)
: null;
// Convert arrowDrag's viewport coords → board-relative coords for the SVG
// preview line. Recomputed every render; cheap.
const dragPreview = useMemo<ArrowDragPreview | null>(() => {
if (!arrowDrag || !arrowDrag.moved) {
return null;
}
const boardRect = boardRef.current?.getBoundingClientRect();
const sourceEl = cardRegistry.get(
makeCardKey(arrowDrag.sourcePlayerId, arrowDrag.sourceZone, arrowDrag.sourceCardId),
);
if (!boardRect || !sourceEl) {
return null;
}
const sourceRect = sourceEl.getBoundingClientRect();
return {
x1: sourceRect.left + sourceRect.width / 2 - boardRect.left,
y1: sourceRect.top + sourceRect.height / 2 - boardRect.top,
x2: arrowDrag.currentX - boardRect.left,
y2: arrowDrag.currentY - boardRect.top,
color: App.rgbaToCss(App.ArrowColor.RED),
};
}, [arrowDrag, cardRegistry, boardRef]);
const handleCardClick = useCallback(
(ownerPlayerId: number, zone: string, card: Data.ServerInfo_Card) => {
if (gameId == null) {
return;
}
// Pending-attach (from CardContextMenu "Attach to card…") takes
// precedence over pending-arrow because it was activated by a later menu
// action. Click on the pending source to cancel.
if (pendingAttach) {
if (
pendingAttach.sourcePlayerId === ownerPlayerId &&
pendingAttach.sourceZone === zone &&
pendingAttach.sourceCardId === card.id
) {
setPendingAttach(null);
return;
}
webClient.request.game.attachCard(gameId, {
startZone: pendingAttach.sourceZone,
cardId: pendingAttach.sourceCardId,
targetPlayerId: ownerPlayerId,
targetZone: zone,
targetCardId: card.id,
});
setPendingAttach(null);
return;
}
if (!pendingArrow) {
return;
}
// Cancel if user re-clicks the pending source.
if (
pendingArrow.sourcePlayerId === ownerPlayerId &&
pendingArrow.sourceZone === zone &&
pendingArrow.sourceCardId === card.id
) {
setPendingArrow(null);
return;
}
// Desktop parity: arrow from local-hand → non-hand target auto-plays the
// card (card_item.cpp:243-250). The server re-keys the moved card id, so
// we resolve this as a play-card intent and drop the arrow command.
if (
pendingArrow.sourceZone === App.ZoneName.HAND &&
pendingArrow.sourcePlayerId === game?.localPlayerId &&
zone !== App.ZoneName.HAND
) {
webClient.request.game.moveCard(gameId, {
startPlayerId: pendingArrow.sourcePlayerId,
startZone: pendingArrow.sourceZone,
cardsToMove: { card: [{ cardId: pendingArrow.sourceCardId }] },
targetPlayerId: pendingArrow.sourcePlayerId,
targetZone: App.ZoneName.TABLE,
x: 0,
y: 0,
isReversed: false,
});
setPendingArrow(null);
return;
}
webClient.request.game.createArrow(gameId, {
startPlayerId: pendingArrow.sourcePlayerId,
startZone: pendingArrow.sourceZone,
startCardId: pendingArrow.sourceCardId,
targetPlayerId: ownerPlayerId,
targetZone: zone,
targetCardId: card.id,
arrowColor: App.ArrowColor.RED,
});
setPendingArrow(null);
},
[gameId, game?.localPlayerId, pendingArrow, pendingAttach, webClient],
);
const handleCardDoubleClick = useCallback(
(sourceZone: string, card: Data.ServerInfo_Card) => {
if (sourceZone !== App.ZoneName.TABLE || gameId == null) {
return;
}
// Desktop's arrow drag owns the pointer while active; mirror that by
// short-circuiting tap-toggle while a pending arrow/attach is armed.
if (pendingArrow || pendingAttach) {
return;
}
webClient.request.game.setCardAttr(gameId, {
zone: sourceZone,
cardId: card.id,
attribute: Data.CardAttribute.AttrTapped,
attrValue: card.tapped ? '0' : '1',
});
},
[gameId, pendingArrow, pendingAttach, webClient],
);
const startPendingArrow = useCallback((source: PendingArrow) => {
setPendingArrow(source);
}, []);
const startPendingAttach = useCallback((source: PendingAttach) => {
setPendingAttach(source);
}, []);
const cancelPendingOnDragStart = useCallback(() => {
setPendingArrow((prev) => (prev ? null : prev));
setPendingAttach((prev) => (prev ? null : prev));
}, []);
return {
arrowSourceKey,
dragPreview,
handleBoardMouseDown,
handleCardClick,
handleCardDoubleClick,
startPendingArrow,
startPendingAttach,
cancelPendingOnDragStart,
};
}

View file

@ -0,0 +1,730 @@
import { useCallback, useState } from 'react';
import { DEFAULT_DIE_COUNT, DEFAULT_DIE_SIDES, type SideboardPlanMove } from '@app/dialogs';
import { useWebClient, type GameAccess } from '@app/hooks';
import { App, Data, type Enriched } from '@app/types';
export interface AnchorPosition {
top: number;
left: number;
}
export interface ZoneViewTarget {
playerId: number;
zoneName: string;
}
export interface CardMenuState {
card: Data.ServerInfo_Card;
sourcePlayerId: number;
sourceZone: string;
anchorPosition: AnchorPosition;
}
export interface ZoneMenuState {
playerId: number;
zoneName: string;
anchorPosition: AnchorPosition;
}
export interface PromptState {
title: string;
label: string;
initialValue?: string;
helperText?: string;
validate?: (value: string) => string | null;
onSubmit: (value: string) => void;
}
export interface RevealState {
title: string;
zoneName: string;
zoneLabel: string;
showCountInput: boolean;
defaultCount: number;
onSubmit: (args: { targetPlayerId: number; topCards: number }) => void;
}
export type ConcedeConfirm = 'concede' | 'unconcede' | null;
export interface StartPendingSource {
sourcePlayerId: number;
sourceZone: string;
sourceCardId: number;
}
export interface GameDialogs {
// Card/zone/player/hand menus
cardMenu: CardMenuState | null;
zoneMenu: ZoneMenuState | null;
playerMenu: AnchorPosition | null;
handMenu: AnchorPosition | null;
closeCardMenu: () => void;
closeZoneMenu: () => void;
closePlayerMenu: () => void;
closeHandMenu: () => void;
handleCardContextMenu: (
sourcePlayerId: number,
sourceZone: string,
card: Data.ServerInfo_Card,
event: React.MouseEvent,
) => void;
handleZoneContextMenu: (
playerId: number,
zoneName: string,
event: React.MouseEvent,
) => void;
handlePlayerContextMenu: (event: React.MouseEvent) => void;
handleHandContextMenu: (event: React.MouseEvent) => void;
// Zone-view dialog stack
zoneViews: ZoneViewTarget[];
handleZoneClick: (playerId: number, zoneName: string) => void;
handleCloseZoneView: (playerId: number, zoneName: string) => void;
// Prompt dialog
prompt: PromptState | null;
closePrompt: () => void;
// Roll die dialog
rollDieOpen: boolean;
lastDieSides: number;
lastDieCount: number;
openRollDie: () => void;
closeRollDie: () => void;
handleRollDieSubmit: (args: { sides: number; count: number }) => void;
// Counter / token / sideboard / game info / concede
createCounterOpen: boolean;
openCreateCounter: () => void;
closeCreateCounter: () => void;
handleCreateCounterSubmit: (args: {
name: string;
color: { r: number; g: number; b: number; a: number };
}) => void;
createTokenOpen: boolean;
openCreateToken: () => void;
closeCreateToken: () => void;
handleCreateTokenSubmit: (args: {
name: string;
color: string;
pt: string;
annotation: string;
destroyOnZoneChange: boolean;
faceDown: boolean;
}) => void;
sideboardOpen: boolean;
openSideboard: () => void;
closeSideboard: () => void;
handleSideboardSubmit: (moveList: SideboardPlanMove[]) => void;
handleToggleSideboardLock: (locked: boolean) => void;
gameInfoOpen: boolean;
openGameInfo: () => void;
closeGameInfo: () => void;
concedeConfirm: ConcedeConfirm;
openConcede: () => void;
openUnconcede: () => void;
closeConcedeConfirm: () => void;
confirmConcede: () => void;
confirmUnconcede: () => void;
// Reveal-cards dialog
revealState: RevealState | null;
closeReveal: () => void;
// Card context menu action handlers
handleRequestSetPT: () => void;
handleRequestSetAnnotation: () => void;
handleRequestSetCardCounter: () => void;
handleRequestDrawArrow: () => void;
handleRequestAttach: () => void;
handleRequestMoveToLibraryAt: () => void;
// Zone context menu action handlers
handleRequestDrawN: () => void;
handleRequestDumpN: () => void;
handleRequestRevealTopN: () => void;
handleRequestRevealZone: () => void;
// Hand context menu action handlers
handleRequestChooseMulligan: () => void;
handleRequestRevealHand: () => void;
handleRequestRevealRandom: () => void;
}
export interface UseGameDialogsArgs {
gameId: number | undefined;
game: Enriched.GameEntry | undefined;
localPlayer: Enriched.PlayerEntry | undefined;
localAccess: GameAccess;
isSpectator: boolean;
startPendingArrow: (source: StartPendingSource) => void;
startPendingAttach: (source: StartPendingSource) => void;
}
export function useGameDialogs({
gameId,
game,
localPlayer,
localAccess,
isSpectator,
startPendingArrow,
startPendingAttach,
}: UseGameDialogsArgs): GameDialogs {
const webClient = useWebClient();
const [zoneViews, setZoneViews] = useState<ZoneViewTarget[]>([]);
const [cardMenu, setCardMenu] = useState<CardMenuState | null>(null);
const [zoneMenu, setZoneMenu] = useState<ZoneMenuState | null>(null);
const [prompt, setPrompt] = useState<PromptState | null>(null);
const [rollDieOpen, setRollDieOpen] = useState(false);
const [lastDieSides, setLastDieSides] = useState(DEFAULT_DIE_SIDES);
const [lastDieCount, setLastDieCount] = useState(DEFAULT_DIE_COUNT);
const [createCounterOpen, setCreateCounterOpen] = useState(false);
const [createTokenOpen, setCreateTokenOpen] = useState(false);
const [sideboardOpen, setSideboardOpen] = useState(false);
const [revealState, setRevealState] = useState<RevealState | null>(null);
const [playerMenu, setPlayerMenu] = useState<AnchorPosition | null>(null);
const [handMenu, setHandMenu] = useState<AnchorPosition | null>(null);
const [concedeConfirm, setConcedeConfirm] = useState<ConcedeConfirm>(null);
const [gameInfoOpen, setGameInfoOpen] = useState(false);
const handleZoneClick = useCallback((playerId: number, zoneName: string) => {
setZoneViews((prev) => {
if (prev.some((v) => v.playerId === playerId && v.zoneName === zoneName)) {
return prev;
}
return [...prev, { playerId, zoneName }];
});
}, []);
const handleCloseZoneView = useCallback((playerId: number, zoneName: string) => {
setZoneViews((prev) =>
prev.filter((v) => !(v.playerId === playerId && v.zoneName === zoneName)),
);
}, []);
const handleCardContextMenu = useCallback(
(
sourcePlayerId: number,
sourceZone: string,
card: Data.ServerInfo_Card,
event: React.MouseEvent,
) => {
event.preventDefault();
setZoneMenu(null);
setCardMenu({
card,
sourcePlayerId,
sourceZone,
anchorPosition: { top: event.clientY, left: event.clientX },
});
},
[],
);
const handleZoneContextMenu = useCallback(
(playerId: number, zoneName: string, event: React.MouseEvent) => {
if (playerId !== game?.localPlayerId) {
return;
}
const supported =
zoneName === App.ZoneName.DECK ||
zoneName === App.ZoneName.GRAVE ||
zoneName === App.ZoneName.EXILE;
if (!supported) {
return;
}
event.preventDefault();
setCardMenu(null);
setZoneMenu({
playerId,
zoneName,
anchorPosition: { top: event.clientY, left: event.clientX },
});
},
[game?.localPlayerId],
);
const handlePlayerContextMenu = useCallback(
(event: React.MouseEvent) => {
if (gameId == null || isSpectator || localAccess.canAct === false) {
return;
}
event.preventDefault();
setPlayerMenu({ top: event.clientY, left: event.clientX });
},
[gameId, isSpectator, localAccess.canAct],
);
const handleHandContextMenu = useCallback(
(event: React.MouseEvent) => {
if (gameId == null || isSpectator || localAccess.canAct === false) {
return;
}
event.preventDefault();
setHandMenu({ top: event.clientY, left: event.clientX });
},
[gameId, isSpectator, localAccess.canAct],
);
const handleRequestSetPT = useCallback(() => {
const menu = cardMenu;
if (!menu || gameId == null) {
return;
}
setPrompt({
title: 'Set power/toughness',
label: 'P/T (e.g. 3/3)',
initialValue: menu.card.pt ?? '',
onSubmit: (value) => {
webClient.request.game.setCardAttr(gameId, {
zone: menu.sourceZone,
cardId: menu.card.id,
attribute: Data.CardAttribute.AttrPT,
attrValue: value,
});
setPrompt(null);
},
});
}, [cardMenu, gameId, webClient]);
const handleRequestSetAnnotation = useCallback(() => {
const menu = cardMenu;
if (!menu || gameId == null) {
return;
}
setPrompt({
title: 'Set annotation',
label: 'Annotation',
initialValue: menu.card.annotation ?? '',
onSubmit: (value) => {
webClient.request.game.setCardAttr(gameId, {
zone: menu.sourceZone,
cardId: menu.card.id,
attribute: Data.CardAttribute.AttrAnnotation,
attrValue: value,
});
setPrompt(null);
},
});
}, [cardMenu, gameId, webClient]);
const handleRequestSetCardCounter = useCallback(() => {
const menu = cardMenu;
if (!menu || gameId == null) {
return;
}
const existing = menu.card.counterList.find((c) => c.id === 0);
setPrompt({
title: 'Set card counter',
label: 'Counter value',
initialValue: String(existing?.value ?? 0),
validate: (v) => (/^-?\d+$/.test(v) ? null : 'Enter an integer'),
onSubmit: (value) => {
webClient.request.game.setCardCounter(gameId, {
zone: menu.sourceZone,
cardId: menu.card.id,
counterId: 0,
counterValue: Number(value),
});
setPrompt(null);
},
});
}, [cardMenu, gameId, webClient]);
const handleRequestDrawArrow = useCallback(() => {
const menu = cardMenu;
if (!menu) {
return;
}
startPendingArrow({
sourcePlayerId: menu.sourcePlayerId,
sourceZone: menu.sourceZone,
sourceCardId: menu.card.id,
});
}, [cardMenu, startPendingArrow]);
const handleRequestAttach = useCallback(() => {
const menu = cardMenu;
if (!menu) {
return;
}
startPendingAttach({
sourcePlayerId: menu.sourcePlayerId,
sourceZone: menu.sourceZone,
sourceCardId: menu.card.id,
});
}, [cardMenu, startPendingAttach]);
const handleRequestMoveToLibraryAt = useCallback(() => {
const menu = cardMenu;
if (!menu || gameId == null || game == null) {
return;
}
// Desktop prompts for a 1-indexed position into the library, then
// internally subtracts 1 for the protocol's 0-indexed x-coordinate.
setPrompt({
title: 'Move to library at position',
label: 'Position (1 = top)',
initialValue: '1',
validate: (v) => (/^[1-9]\d*$/.test(v) ? null : 'Enter a positive integer'),
onSubmit: (value) => {
webClient.request.game.moveCard(gameId, {
startPlayerId: menu.sourcePlayerId,
startZone: menu.sourceZone,
cardsToMove: { card: [{ cardId: menu.card.id }] },
targetPlayerId: game.localPlayerId,
targetZone: App.ZoneName.DECK,
x: Math.max(0, Number(value) - 1),
y: 0,
isReversed: false,
});
setPrompt(null);
},
});
}, [cardMenu, game, gameId, webClient]);
const handleRequestDrawN = useCallback(() => {
if (gameId == null) {
return;
}
setPrompt({
title: 'Draw N cards',
label: 'Number of cards',
initialValue: '1',
validate: (v) => (/^[1-9]\d*$/.test(v) ? null : 'Enter a positive integer'),
onSubmit: (value) => {
webClient.request.game.drawCards(gameId, { number: Number(value) });
setPrompt(null);
},
});
}, [gameId, webClient]);
const handleRequestDumpN = useCallback(() => {
if (gameId == null) {
return;
}
setPrompt({
title: 'Dump top N',
label: 'Number of cards',
initialValue: '1',
validate: (v) => (/^[1-9]\d*$/.test(v) ? null : 'Enter a positive integer'),
onSubmit: (value) => {
webClient.request.game.dumpZone(gameId, {
playerId: game!.localPlayerId,
zoneName: App.ZoneName.DECK,
numberCards: Number(value),
isReversed: false,
});
setPrompt(null);
},
});
}, [game, gameId, webClient]);
const handleRollDieSubmit = useCallback(
({ sides, count }: { sides: number; count: number }) => {
if (gameId == null) {
return;
}
webClient.request.game.rollDie(gameId, { sides, count });
setLastDieSides(sides);
setLastDieCount(count);
setRollDieOpen(false);
},
[gameId, webClient],
);
const handleCreateCounterSubmit = useCallback(
({
name,
color,
}: {
name: string;
color: { r: number; g: number; b: number; a: number };
}) => {
if (gameId == null) {
return;
}
webClient.request.game.createCounter(gameId, {
counterName: name,
counterColor: color,
radius: 1,
value: 0,
});
setCreateCounterOpen(false);
},
[gameId, webClient],
);
const handleCreateTokenSubmit = useCallback(
(args: {
name: string;
color: string;
pt: string;
annotation: string;
destroyOnZoneChange: boolean;
faceDown: boolean;
}) => {
if (gameId == null) {
return;
}
webClient.request.game.createToken(gameId, {
zone: App.ZoneName.TABLE,
cardName: args.name,
color: args.color,
pt: args.pt,
annotation: args.annotation,
destroyOnZoneChange: args.destroyOnZoneChange,
x: 0,
y: 0,
faceDown: args.faceDown,
targetCardId: -1,
});
setCreateTokenOpen(false);
},
[gameId, webClient],
);
const handleSideboardSubmit = useCallback(
(moveList: SideboardPlanMove[]) => {
if (gameId == null) {
return;
}
webClient.request.game.setSideboardPlan(gameId, { moveList });
setSideboardOpen(false);
},
[gameId, webClient],
);
const handleToggleSideboardLock = useCallback(
(locked: boolean) => {
if (gameId == null) {
return;
}
webClient.request.game.setSideboardLock(gameId, { locked });
},
[gameId, webClient],
);
const handleRequestChooseMulligan = useCallback(() => {
if (gameId == null) {
return;
}
// Desktop's DlgMulligan (player_actions.cpp actMulligan) accepts any
// integer in [-handSize, handSize + deckSize]. 0 and negative values are
// "relative to current hand size" — doMulligan computes
// `handSize + number` before dispatching. Seeding with the configured
// starting hand size (7) matches desktop's default.
const handSize = localPlayer?.zones[App.ZoneName.HAND]?.cardCount ?? 0;
const deckSize = localPlayer?.zones[App.ZoneName.DECK]?.cardCount ?? 0;
const min = -handSize;
const max = handSize + deckSize;
setPrompt({
title: 'Take mulligan',
label: 'New hand size',
initialValue: '7',
helperText: '0 and lower are in comparison to current hand size.',
validate: (v) => {
if (!/^-?\d+$/.test(v)) {
return 'Enter an integer.';
}
const n = Number(v);
if (n < min || n > max) {
return `Enter an integer between ${min} and ${max}.`;
}
return null;
},
onSubmit: (value) => {
const input = Number(value);
const resolved = input < 1 ? handSize + input : input;
webClient.request.game.mulligan(gameId, { number: resolved });
setPrompt(null);
},
});
}, [gameId, localPlayer, webClient]);
const handleRequestRevealHand = useCallback(() => {
if (gameId == null) {
return;
}
setRevealState({
title: 'Reveal hand',
zoneName: App.ZoneName.HAND,
zoneLabel: 'Hand',
showCountInput: false,
defaultCount: 1,
onSubmit: ({ targetPlayerId }) => {
webClient.request.game.revealCards(gameId, {
zoneName: App.ZoneName.HAND,
playerId: targetPlayerId,
topCards: -1,
});
setRevealState(null);
},
});
}, [gameId, webClient]);
const handleRequestRevealRandom = useCallback(() => {
if (gameId == null) {
return;
}
// Desktop's RANDOM_CARD_FROM_ZONE sentinel (-2); see
// cockatrice/src/game/player/player_actions.h:47 and
// actRevealRandomHandCard at player_actions.cpp:1705-1712.
const RANDOM_CARD_FROM_ZONE = -2;
setRevealState({
title: 'Reveal random card',
zoneName: App.ZoneName.HAND,
zoneLabel: 'Hand (random)',
showCountInput: false,
defaultCount: 1,
onSubmit: ({ targetPlayerId }) => {
webClient.request.game.revealCards(gameId, {
zoneName: App.ZoneName.HAND,
cardId: [RANDOM_CARD_FROM_ZONE],
playerId: targetPlayerId,
topCards: -1,
});
setRevealState(null);
},
});
}, [gameId, webClient]);
const handleRequestRevealTopN = useCallback(() => {
if (gameId == null) {
return;
}
setRevealState({
title: 'Reveal top N cards',
zoneName: App.ZoneName.DECK,
zoneLabel: 'Library',
showCountInput: true,
defaultCount: 1,
onSubmit: ({ targetPlayerId, topCards }) => {
webClient.request.game.revealCards(gameId, {
zoneName: App.ZoneName.DECK,
playerId: targetPlayerId,
topCards,
});
setRevealState(null);
},
});
}, [gameId, webClient]);
const handleRequestRevealZone = useCallback(() => {
if (gameId == null || zoneMenu == null) {
return;
}
const { zoneName } = zoneMenu;
const label =
zoneName === App.ZoneName.GRAVE ? 'Graveyard' :
zoneName === App.ZoneName.EXILE ? 'Exile' : zoneName;
setRevealState({
title: `Reveal ${label.toLowerCase()}`,
zoneName,
zoneLabel: label,
showCountInput: false,
defaultCount: 1,
onSubmit: ({ targetPlayerId }) => {
webClient.request.game.revealCards(gameId, {
zoneName,
playerId: targetPlayerId,
topCards: -1,
});
setRevealState(null);
},
});
}, [gameId, zoneMenu, webClient]);
const confirmConcede = useCallback(() => {
if (gameId != null) {
webClient.request.game.concede(gameId);
}
setConcedeConfirm(null);
}, [gameId, webClient]);
const confirmUnconcede = useCallback(() => {
if (gameId != null) {
webClient.request.game.unconcede(gameId);
}
setConcedeConfirm(null);
}, [gameId, webClient]);
return {
cardMenu,
zoneMenu,
playerMenu,
handMenu,
closeCardMenu: useCallback(() => setCardMenu(null), []),
closeZoneMenu: useCallback(() => setZoneMenu(null), []),
closePlayerMenu: useCallback(() => setPlayerMenu(null), []),
closeHandMenu: useCallback(() => setHandMenu(null), []),
handleCardContextMenu,
handleZoneContextMenu,
handlePlayerContextMenu,
handleHandContextMenu,
zoneViews,
handleZoneClick,
handleCloseZoneView,
prompt,
closePrompt: useCallback(() => setPrompt(null), []),
rollDieOpen,
lastDieSides,
lastDieCount,
openRollDie: useCallback(() => setRollDieOpen(true), []),
closeRollDie: useCallback(() => setRollDieOpen(false), []),
handleRollDieSubmit,
createCounterOpen,
openCreateCounter: useCallback(() => setCreateCounterOpen(true), []),
closeCreateCounter: useCallback(() => setCreateCounterOpen(false), []),
handleCreateCounterSubmit,
createTokenOpen,
openCreateToken: useCallback(() => setCreateTokenOpen(true), []),
closeCreateToken: useCallback(() => setCreateTokenOpen(false), []),
handleCreateTokenSubmit,
sideboardOpen,
openSideboard: useCallback(() => setSideboardOpen(true), []),
closeSideboard: useCallback(() => setSideboardOpen(false), []),
handleSideboardSubmit,
handleToggleSideboardLock,
gameInfoOpen,
openGameInfo: useCallback(() => setGameInfoOpen(true), []),
closeGameInfo: useCallback(() => setGameInfoOpen(false), []),
concedeConfirm,
openConcede: useCallback(() => setConcedeConfirm('concede'), []),
openUnconcede: useCallback(() => setConcedeConfirm('unconcede'), []),
closeConcedeConfirm: useCallback(() => setConcedeConfirm(null), []),
confirmConcede,
confirmUnconcede,
revealState,
closeReveal: useCallback(() => setRevealState(null), []),
handleRequestSetPT,
handleRequestSetAnnotation,
handleRequestSetCardCounter,
handleRequestDrawArrow,
handleRequestAttach,
handleRequestMoveToLibraryAt,
handleRequestDrawN,
handleRequestDumpN,
handleRequestRevealTopN,
handleRequestRevealZone,
handleRequestChooseMulligan,
handleRequestRevealHand,
handleRequestRevealRandom,
};
}

View file

@ -0,0 +1,112 @@
import { useCallback, useState } from 'react';
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
import { useWebClient } from '@app/hooks';
import { App, Data } from '@app/types';
export interface GameDnd {
activeCard: Data.ServerInfo_Card | null;
handleDragStart: (event: DragStartEvent) => void;
handleDragEnd: (event: DragEndEvent) => void;
handleDragCancel: () => void;
}
export interface UseGameDndArgs {
gameId: number | undefined;
onDragStart: () => void;
}
export function useGameDnd({ gameId, onDragStart }: UseGameDndArgs): GameDnd {
const webClient = useWebClient();
const [activeCard, setActiveCard] = useState<Data.ServerInfo_Card | null>(null);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const data = event.active.data.current as
| { card: Data.ServerInfo_Card }
| undefined;
setActiveCard(data?.card ?? null);
// Starting a drag cancels any armed pending-arrow or pending-attach —
// dnd-kit owns the pointer during the drag, matching desktop where the
// arrow draw from context menu is aborted if the user grabs a card.
onDragStart();
},
[onDragStart],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveCard(null);
if (!gameId || !event.over || !event.active.data.current) {
return;
}
const source = event.active.data.current as {
card: Data.ServerInfo_Card;
sourcePlayerId: number;
sourceZone: string;
};
const target = event.over.data.current as {
targetPlayerId: number;
targetZone: string;
row?: number;
attachTarget?: boolean;
targetCardId?: number;
};
// Drop onto another card on the table → attach source to target.
// Desktop's actAttach is only initiated from a table card, so source
// must also be TABLE. Non-TABLE drops onto a table card fall through
// to the normal moveCard branch (drop becomes "move to that row").
if (
target.attachTarget &&
target.targetCardId != null &&
source.sourceZone === App.ZoneName.TABLE
) {
// Guard no-op self-drop (source === target).
if (
source.sourcePlayerId === target.targetPlayerId &&
source.sourceZone === target.targetZone &&
source.card.id === target.targetCardId
) {
return;
}
webClient.request.game.attachCard(gameId, {
startZone: source.sourceZone,
cardId: source.card.id,
targetPlayerId: target.targetPlayerId,
targetZone: target.targetZone,
targetCardId: target.targetCardId,
});
return;
}
const sameZone =
source.sourcePlayerId === target.targetPlayerId &&
source.sourceZone === target.targetZone;
if (sameZone && source.sourceZone === App.ZoneName.TABLE && (source.card.y ?? 0) === (target.row ?? 0)) {
return;
}
if (sameZone && source.sourceZone !== App.ZoneName.TABLE) {
return;
}
webClient.request.game.moveCard(gameId, {
startPlayerId: source.sourcePlayerId,
startZone: source.sourceZone,
cardsToMove: { card: [{ cardId: source.card.id }] },
targetPlayerId: target.targetPlayerId,
targetZone: target.targetZone,
x: 0,
y: target.row ?? 0,
isReversed: false,
});
},
[gameId, webClient],
);
const handleDragCancel = useCallback(() => {
setActiveCard(null);
}, []);
return { activeCard, handleDragStart, handleDragEnd, handleDragCancel };
}

View file

@ -0,0 +1,29 @@
import { useNavigate } from 'react-router-dom';
import { useToast } from '@app/components';
import { useGameLifecycle } from '@app/hooks';
import { App } from '@app/types';
export function useGameLifecycleNavigation(gameId: number | undefined): void {
const navigate = useNavigate();
const kickedToast = useToast({
key: 'game-kicked',
children: 'You were kicked from the game',
});
const gameClosedToast = useToast({
key: 'game-closed',
children: 'The game was closed by the host',
});
useGameLifecycle(gameId, {
onKicked: () => {
kickedToast.openToast();
navigate(App.RouteEnum.SERVER);
},
onGameClosed: () => {
gameClosedToast.openToast();
navigate(App.RouteEnum.SERVER);
},
});
}

View file

@ -0,0 +1,53 @@
import { useEffect, useMemo, useState } from 'react';
import type { Enriched } from '@app/types';
export interface OpponentEntry {
playerId: number;
name: string;
}
export interface GameOpponentSelector {
opponents: OpponentEntry[];
selectedOpponentId: number | undefined;
setSelectedOpponentId: (id: number | undefined) => void;
shownOpponentId: number | undefined;
revealPlayers: OpponentEntry[];
}
export function useGameOpponentSelector(
game: Enriched.GameEntry | undefined,
): GameOpponentSelector {
const [selectedOpponentId, setSelectedOpponentId] = useState<number | undefined>();
const opponents = useMemo<OpponentEntry[]>(() => {
if (!game) {
return [];
}
return Object.values(game.players)
.filter((p) => p.properties.playerId !== game.localPlayerId)
.map((p) => ({
playerId: p.properties.playerId,
name: p.properties.userInfo?.name ?? `p${p.properties.playerId}`,
}));
}, [game]);
useEffect(() => {
if (selectedOpponentId == null && opponents.length > 0) {
setSelectedOpponentId(opponents[0].playerId);
}
if (selectedOpponentId != null && !opponents.some((o) => o.playerId === selectedOpponentId)) {
setSelectedOpponentId(opponents[0]?.playerId);
}
}, [opponents, selectedOpponentId]);
const shownOpponentId = selectedOpponentId ?? opponents[0]?.playerId;
return {
opponents,
selectedOpponentId,
setSelectedOpponentId,
shownOpponentId,
revealPlayers: opponents,
};
}

View file

@ -1,5 +1,4 @@
import React, { useState, useEffect } from 'react';
import { NavLink, useNavigate, generatePath } from 'react-router-dom';
import { NavLink, generatePath } from 'react-router-dom';
import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
@ -9,75 +8,25 @@ import MailOutlineRoundedIcon from '@mui/icons-material/MailOutlineRounded';
import MenuRoundedIcon from '@mui/icons-material/MenuRounded';
import { CardImportDialog } from '@app/dialogs';
import { useWebClient } from '@app/hooks';
import { Images } from '@app/images';
import { RoomsSelectors, ServerSelectors } from '@app/store';
import { App } from '@app/types';
import { useAppSelector } from '@app/store';
import { useLeftNav } from './useLeftNav';
import './LeftNav.css';
interface LeftNavState {
anchorEl: Element;
showCardImportDialog: boolean;
options: string[];
}
const LeftNav = () => {
const joinedRooms = useAppSelector(state => RoomsSelectors.getJoinedRooms(state));
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
const isModerator = useAppSelector(ServerSelectors.getIsUserModerator);
const navigate = useNavigate();
const webClient = useWebClient();
const [state, setState] = useState<LeftNavState>({
anchorEl: null,
showCardImportDialog: false,
options: [],
});
useEffect(() => {
let options: string[] = [
'Account',
'Replays',
];
if (isModerator) {
options = [
...options,
'Administration',
'Logs'
];
}
setState(s => ({ ...s, options }));
}, [isModerator]);
const handleMenuOpen = (event) => {
setState(s => ({ ...s, anchorEl: event.target }));
}
const handleMenuItemClick = (option: string) => {
const route = App.RouteEnum[option.toUpperCase()];
navigate(generatePath(route));
}
const handleMenuClose = () => {
setState(s => ({ ...s, anchorEl: null }));
}
const leaveRoom = (event, roomId) => {
event.preventDefault();
webClient.request.rooms.leaveRoom(roomId);
};
const openImportCardWizard = () => {
setState(s => ({ ...s, showCardImportDialog: true }));
handleMenuClose();
}
const closeImportCardWizard = () => {
setState(s => ({ ...s, showCardImportDialog: false }));
}
const {
joinedRooms,
isConnected,
state,
handleMenuOpen,
handleMenuItemClick,
handleMenuClose,
leaveRoom,
openImportCardWizard,
closeImportCardWizard,
} = useLeftNav();
return (
<div className="LeftNav__container">
@ -86,11 +35,11 @@ const LeftNav = () => {
<NavLink to={App.RouteEnum.SERVER}>
<img src={Images.Logo} alt="logo" />
</NavLink>
{ isConnected && (
{isConnected && (
<span className="LeftNav-server__indicator"></span>
) }
)}
</div>
{ isConnected && (
{isConnected && (
<div className="LeftNav-content">
<nav className="LeftNav-nav">
<nav className="LeftNav-nav__links">
@ -110,7 +59,7 @@ const LeftNav = () => {
{joinedRooms.map((room) => (
<div className="LeftNav-nav__link-menu__item" key={room.info.roomId}>
<NavLink className="LeftNav-nav__link-menu__btn"
to={ generatePath(App.RouteEnum.ROOM, { roomId: room.info.roomId.toString() }) }
to={generatePath(App.RouteEnum.ROOM, { roomId: room.info.roomId.toString() })}
>
{room.info.name}
@ -123,13 +72,13 @@ const LeftNav = () => {
</div>
</div>
<div className="LeftNav-nav__link">
<NavLink className="LeftNav-nav__link-btn" to={ App.RouteEnum.GAME }>
<NavLink className="LeftNav-nav__link-btn" to={App.RouteEnum.GAME}>
Games
<ArrowDropDownIcon className="LeftNav-nav__link-btn__icon" fontSize="small" />
</NavLink>
</div>
<div className="LeftNav-nav__link">
<NavLink className="LeftNav-nav__link-btn" to={ App.RouteEnum.DECKS }>
<NavLink className="LeftNav-nav__link-btn" to={App.RouteEnum.DECKS}>
Decks
<ArrowDropDownIcon className="LeftNav-nav__link-btn__icon" fontSize="small" />
</NavLink>
@ -173,7 +122,7 @@ const LeftNav = () => {
</div>
</nav>
</div>
) }
)}
</div>
<CardImportDialog
@ -182,6 +131,6 @@ const LeftNav = () => {
></CardImportDialog>
</div>
);
}
};
export default LeftNav;

View file

@ -0,0 +1,93 @@
import { useEffect, useState } from 'react';
import { useNavigate, generatePath } from 'react-router-dom';
import { useWebClient } from '@app/hooks';
import { RoomsSelectors, ServerSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
interface LeftNavState {
anchorEl: Element | null;
showCardImportDialog: boolean;
options: string[];
}
export interface LeftNav {
joinedRooms: any[];
isConnected: boolean;
state: LeftNavState;
handleMenuOpen: (event: React.MouseEvent) => void;
handleMenuItemClick: (option: string) => void;
handleMenuClose: () => void;
leaveRoom: (event: React.MouseEvent, roomId: number) => void;
openImportCardWizard: () => void;
closeImportCardWizard: () => void;
}
export function useLeftNav(): LeftNav {
const joinedRooms = useAppSelector((state) => RoomsSelectors.getJoinedRooms(state));
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
const isModerator = useAppSelector(ServerSelectors.getIsUserModerator);
const navigate = useNavigate();
const webClient = useWebClient();
const [state, setState] = useState<LeftNavState>({
anchorEl: null,
showCardImportDialog: false,
options: [],
});
useEffect(() => {
let options: string[] = [
'Account',
'Replays',
];
if (isModerator) {
options = [
...options,
'Administration',
'Logs',
];
}
setState((s) => ({ ...s, options }));
}, [isModerator]);
const handleMenuOpen = (event: React.MouseEvent) => {
setState((s) => ({ ...s, anchorEl: event.target as Element }));
};
const handleMenuItemClick = (option: string) => {
const route = App.RouteEnum[option.toUpperCase()];
navigate(generatePath(route));
};
const handleMenuClose = () => {
setState((s) => ({ ...s, anchorEl: null }));
};
const leaveRoom = (event: React.MouseEvent, roomId: number) => {
event.preventDefault();
webClient.request.rooms.leaveRoom(roomId);
};
const openImportCardWizard = () => {
setState((s) => ({ ...s, showCardImportDialog: true }));
handleMenuClose();
};
const closeImportCardWizard = () => {
setState((s) => ({ ...s, showCardImportDialog: false }));
};
return {
joinedRooms,
isConnected,
state,
handleMenuOpen,
handleMenuItemClick,
handleMenuClose,
leaveRoom,
openImportCardWizard,
closeImportCardWizard,
};
}

View file

@ -1,4 +1,3 @@
import { useState, useCallback, useRef } from 'react';
import { styled } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom';
@ -9,28 +8,25 @@ import Typography from '@mui/material/Typography';
import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog, AccountActivationDialog } from '@app/dialogs';
import { LanguageDropdown } from '@app/components';
import { LoginForm } from '@app/forms';
import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
import { Images } from '@app/images';
import { getHostPort, serverProps } from '@app/services';
import { serverProps } from '@app/services';
import { App } from '@app/types';
import { WebsocketTypes } from '@app/websocket/types';
import { ServerSelectors, ServerTypes } from '@app/store';
import Layout from '../Layout/Layout';
import { useAppSelector } from '@app/store';
import { useLogin } from './useLogin';
import './Login.css';
import { useToast } from '@app/components';
const PREFIX = 'Login';
const classes = {
root: `${PREFIX}-root`
root: `${PREFIX}-root`,
};
const Root = styled('div')(({ theme }) => ({
[`&.${classes.root}`]: {
'& .login-content__header': {
color: theme.palette.success.light
color: theme.palette.success.light,
},
'& .login-content__description': {
@ -61,188 +57,36 @@ const Root = styled('div')(({ theme }) => ({
display: 'flex',
},
},
}
},
}));
const Login = () => {
const description = useAppSelector(s => ServerSelectors.getDescription(s));
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
const connectionAttemptMade = useAppSelector(ServerSelectors.getConnectionAttemptMade);
const webClient = useWebClient();
const { t } = useTranslation();
const [pendingActivationOptions, setPendingActivationOptions] = useState<WebsocketTypes.PendingActivationContext | null>(null);
const rememberLoginRef = useRef<any>(null);
const knownHosts = useKnownHosts();
const [dialogState, setDialogState] = useState({
passwordResetRequestDialog: false,
resetPasswordDialog: false,
registrationDialog: false,
activationDialog: false,
});
const [userToResetPassword, setUserToResetPassword] = useState(null);
const passwordResetToast = useToast({ key: 'password-reset-success', children: t('LoginContainer.toasts.passwordResetSuccess') });
const accountActivatedToast = useToast({
key: 'account-activation-success',
children: t('LoginContainer.toasts.accountActivationSuccess')
});
useReduxEffect(() => {
closeRequestPasswordResetDialog();
openResetPasswordDialog();
}, ServerTypes.RESET_PASSWORD_REQUESTED, []);
useReduxEffect(() => {
passwordResetToast.openToast()
closeResetPasswordDialog();
}, ServerTypes.RESET_PASSWORD_SUCCESS, []);
useReduxEffect(() => {
accountActivatedToast.openToast()
closeActivateAccountDialog();
setPendingActivationOptions(null);
}, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []);
useReduxEffect(({ payload: { options } }) => {
setPendingActivationOptions(options);
closeRegistrationDialog();
openActivateAccountDialog();
}, ServerTypes.ACCOUNT_AWAITING_ACTIVATION, []);
useReduxEffect(() => {
resetSubmitButton();
}, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []);
useReduxEffect(({ payload: { options: { hashedPassword } } }) => {
if (rememberLoginRef.current) {
updateHost(hashedPassword, rememberLoginRef.current);
}
}, ServerTypes.LOGIN_SUCCESSFUL, []);
const showDescription = () => {
return !isConnected && description?.length;
};
const onSubmitLogin = useCallback((loginForm) => {
rememberLoginRef.current = loginForm;
const { userName, password, selectedHost, remember } = loginForm;
const options: Omit<WebsocketTypes.LoginConnectOptions, 'reason'> = {
...getHostPort(selectedHost),
userName,
password,
};
if (remember && !password) {
options.hashedPassword = selectedHost.hashedPassword;
}
webClient.request.authentication.login(options);
}, []);
const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin);
useAutoLogin(handleLogin, connectionAttemptMade);
const updateHost = (hashedPassword, { selectedHost, remember, userName }) => {
knownHosts.update(selectedHost.id, {
remember,
userName: remember ? userName : null,
hashedPassword: remember ? hashedPassword : null,
});
};
const handleRegistrationDialogSubmit = (registerForm) => {
rememberLoginRef.current = registerForm;
const { userName, password, email, country, realName, selectedHost } = registerForm;
webClient.request.authentication.register({
...getHostPort(selectedHost),
userName,
password,
email,
country,
realName,
});
};
const handleAccountActivationDialogSubmit = ({ token }) => {
if (!pendingActivationOptions) {
return;
}
webClient.request.authentication.activateAccount({
host: pendingActivationOptions.host,
port: pendingActivationOptions.port,
userName: pendingActivationOptions.userName,
token,
});
};
const handleRequestPasswordResetDialogSubmit = (form) => {
const { userName, email, selectedHost } = form;
const { host, port } = getHostPort(selectedHost);
if (email) {
webClient.request.authentication.resetPasswordChallenge({ userName, email, host, port });
} else {
setUserToResetPassword(userName);
webClient.request.authentication.resetPasswordRequest({ userName, host, port });
}
};
const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }) => {
const { host, port } = getHostPort(selectedHost);
webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port });
};
const skipTokenRequest = (userName) => {
setUserToResetPassword(userName);
setDialogState(s => ({ ...s,
passwordResetRequestDialog: false,
resetPasswordDialog: true,
}));
};
const closeRequestPasswordResetDialog = () => {
setDialogState(s => ({ ...s, passwordResetRequestDialog: false }));
}
const openRequestPasswordResetDialog = () => {
setDialogState(s => ({ ...s, passwordResetRequestDialog: true }));
}
const closeResetPasswordDialog = () => {
setDialogState(s => ({ ...s, resetPasswordDialog: false }));
}
const openResetPasswordDialog = () => {
setDialogState(s => ({ ...s, resetPasswordDialog: true }));
}
const closeRegistrationDialog = () => {
setDialogState(s => ({ ...s, registrationDialog: false }));
}
const openRegistrationDialog = () => {
setDialogState(s => ({ ...s, registrationDialog: true }));
}
const closeActivateAccountDialog = () => {
setDialogState(s => ({ ...s, activationDialog: false }));
};
const openActivateAccountDialog = () => {
setDialogState(s => ({ ...s, activationDialog: true }));
};
const {
description,
isConnected,
dialogState,
userToResetPassword,
submitButtonDisabled,
handleLogin,
showDescription,
handleRegistrationDialogSubmit,
handleAccountActivationDialogSubmit,
handleRequestPasswordResetDialogSubmit,
handleResetPasswordDialogSubmit,
skipTokenRequest,
closeRequestPasswordResetDialog,
openRequestPasswordResetDialog,
closeResetPasswordDialog,
closeRegistrationDialog,
openRegistrationDialog,
closeActivateAccountDialog,
} = useLogin();
return (
<Layout showNav={false} noHeightLimit={true}>
<Root className={'login overflow-scroll ' + classes.root}>
{ isConnected && <Navigate to={App.RouteEnum.SERVER} />}
{isConnected && <Navigate to={App.RouteEnum.SERVER} />}
<div className="login__wrapper">
<Paper className="login-content">
@ -251,8 +95,8 @@ const Login = () => {
<img src={Images.Logo} alt="logo" />
<span>COCKATRICE</span>
</div>
<Typography variant="h1">{ t('LoginContainer.header.title') }</Typography>
<Typography variant="subtitle1">{ t('LoginContainer.header.subtitle') }</Typography>
<Typography variant="h1">{t('LoginContainer.header.title')}</Typography>
<Typography variant="subtitle1">{t('LoginContainer.header.subtitle')}</Typography>
<div className="login-form">
<LoginForm
onSubmit={handleLogin}
@ -261,30 +105,26 @@ const Login = () => {
/>
</div>
{
showDescription() && (
<Paper className="login-content__connectionStatus">
{description}
</Paper>
)
}
{showDescription() && (
<Paper className="login-content__connectionStatus">
{description}
</Paper>
)}
<div className="login-footer">
<div className="login-footer__register">
<span>{ t('LoginContainer.footer.registerPrompt') }</span>
<Button color="primary" onClick={openRegistrationDialog}>{ t('LoginContainer.footer.registerAction') }</Button>
<span>{t('LoginContainer.footer.registerPrompt')}</span>
<Button color="primary" onClick={openRegistrationDialog}>{t('LoginContainer.footer.registerAction')}</Button>
</div>
<Typography variant="subtitle2">
{ t('LoginContainer.footer.credit') } - { new Date().getUTCFullYear() }
{t('LoginContainer.footer.credit')} - {new Date().getUTCFullYear()}
</Typography>
{
serverProps.REACT_APP_VERSION && (
<Typography variant="subtitle2">
{ t('LoginContainer.footer.version') }: { serverProps.REACT_APP_VERSION }
</Typography>
)
}
{serverProps.REACT_APP_VERSION && (
<Typography variant="subtitle2">
{t('LoginContainer.footer.version')}: {serverProps.REACT_APP_VERSION}
</Typography>
)}
<div className="login-footer__language">
<LanguageDropdown />
@ -321,9 +161,8 @@ const Login = () => {
</div>
</div>
</div>
{ /*<img src={loginGraphic} className="login-content__description-image"/>*/}
<p className="login-content__description-subtitle1">{ t('LoginContainer.content.subtitle1') }</p>
<p className="login-content__description-subtitle2">{ t('LoginContainer.content.subtitle2') }</p>
<p className="login-content__description-subtitle1">{t('LoginContainer.content.subtitle1')}</p>
<p className="login-content__description-subtitle2">{t('LoginContainer.content.subtitle2')}</p>
</div>
</div>
</Paper>
@ -357,6 +196,6 @@ const Login = () => {
</Root>
</Layout>
);
}
};
export default Login;

View file

@ -0,0 +1,239 @@
import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from '@app/components';
import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
import { getHostPort } from '@app/services';
import { ServerSelectors, ServerTypes, useAppSelector } from '@app/store';
import { WebsocketTypes } from '@app/websocket/types';
export interface LoginDialogState {
passwordResetRequestDialog: boolean;
resetPasswordDialog: boolean;
registrationDialog: boolean;
activationDialog: boolean;
}
export interface Login {
description: string | undefined;
isConnected: boolean;
pendingActivationOptions: WebsocketTypes.PendingActivationContext | null;
dialogState: LoginDialogState;
userToResetPassword: string | null;
submitButtonDisabled: boolean;
handleLogin: (form: any) => void;
showDescription: () => boolean;
handleRegistrationDialogSubmit: (form: any) => void;
handleAccountActivationDialogSubmit: (args: { token: string }) => void;
handleRequestPasswordResetDialogSubmit: (form: any) => void;
handleResetPasswordDialogSubmit: (args: any) => void;
skipTokenRequest: (userName: string) => void;
closeRequestPasswordResetDialog: () => void;
openRequestPasswordResetDialog: () => void;
closeResetPasswordDialog: () => void;
closeRegistrationDialog: () => void;
openRegistrationDialog: () => void;
closeActivateAccountDialog: () => void;
}
export function useLogin(): Login {
const description = useAppSelector((s) => ServerSelectors.getDescription(s));
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
const connectionAttemptMade = useAppSelector(ServerSelectors.getConnectionAttemptMade);
const webClient = useWebClient();
const { t } = useTranslation();
const [pendingActivationOptions, setPendingActivationOptions] =
useState<WebsocketTypes.PendingActivationContext | null>(null);
const rememberLoginRef = useRef<any>(null);
const knownHosts = useKnownHosts();
const [dialogState, setDialogState] = useState<LoginDialogState>({
passwordResetRequestDialog: false,
resetPasswordDialog: false,
registrationDialog: false,
activationDialog: false,
});
const [userToResetPassword, setUserToResetPassword] = useState<string | null>(null);
const passwordResetToast = useToast({
key: 'password-reset-success',
children: t('LoginContainer.toasts.passwordResetSuccess'),
});
const accountActivatedToast = useToast({
key: 'account-activation-success',
children: t('LoginContainer.toasts.accountActivationSuccess'),
});
const closeRequestPasswordResetDialog = () => {
setDialogState((s) => ({ ...s, passwordResetRequestDialog: false }));
};
const openRequestPasswordResetDialog = () => {
setDialogState((s) => ({ ...s, passwordResetRequestDialog: true }));
};
const closeResetPasswordDialog = () => {
setDialogState((s) => ({ ...s, resetPasswordDialog: false }));
};
const openResetPasswordDialog = () => {
setDialogState((s) => ({ ...s, resetPasswordDialog: true }));
};
const closeRegistrationDialog = () => {
setDialogState((s) => ({ ...s, registrationDialog: false }));
};
const openRegistrationDialog = () => {
setDialogState((s) => ({ ...s, registrationDialog: true }));
};
const closeActivateAccountDialog = () => {
setDialogState((s) => ({ ...s, activationDialog: false }));
};
const openActivateAccountDialog = () => {
setDialogState((s) => ({ ...s, activationDialog: true }));
};
useReduxEffect(() => {
closeRequestPasswordResetDialog();
openResetPasswordDialog();
}, ServerTypes.RESET_PASSWORD_REQUESTED, []);
useReduxEffect(() => {
passwordResetToast.openToast();
closeResetPasswordDialog();
}, ServerTypes.RESET_PASSWORD_SUCCESS, []);
useReduxEffect(() => {
accountActivatedToast.openToast();
closeActivateAccountDialog();
setPendingActivationOptions(null);
}, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []);
useReduxEffect(({ payload: { options } }) => {
setPendingActivationOptions(options);
closeRegistrationDialog();
openActivateAccountDialog();
}, ServerTypes.ACCOUNT_AWAITING_ACTIVATION, []);
const onSubmitLogin = useCallback((loginForm) => {
rememberLoginRef.current = loginForm;
const { userName, password, selectedHost, remember } = loginForm;
const options: Omit<WebsocketTypes.LoginConnectOptions, 'reason'> = {
...getHostPort(selectedHost),
userName,
password,
};
if (remember && !password) {
options.hashedPassword = selectedHost.hashedPassword;
}
webClient.request.authentication.login(options);
}, []);
const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin);
useReduxEffect(() => {
resetSubmitButton();
}, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []);
const updateHost = (hashedPassword: string, { selectedHost, remember, userName }: any) => {
knownHosts.update(selectedHost.id, {
remember,
userName: remember ? userName : null,
hashedPassword: remember ? hashedPassword : null,
});
};
useReduxEffect(({ payload: { options: { hashedPassword } } }) => {
if (rememberLoginRef.current) {
updateHost(hashedPassword, rememberLoginRef.current);
}
}, ServerTypes.LOGIN_SUCCESSFUL, []);
useAutoLogin(handleLogin, connectionAttemptMade);
const showDescription = () => {
return Boolean(!isConnected && description?.length);
};
const handleRegistrationDialogSubmit = (registerForm: any) => {
rememberLoginRef.current = registerForm;
const { userName, password, email, country, realName, selectedHost } = registerForm;
webClient.request.authentication.register({
...getHostPort(selectedHost),
userName,
password,
email,
country,
realName,
});
};
const handleAccountActivationDialogSubmit = ({ token }: { token: string }) => {
if (!pendingActivationOptions) {
return;
}
webClient.request.authentication.activateAccount({
host: pendingActivationOptions.host,
port: pendingActivationOptions.port,
userName: pendingActivationOptions.userName,
token,
});
};
const handleRequestPasswordResetDialogSubmit = (form: any) => {
const { userName, email, selectedHost } = form;
const { host, port } = getHostPort(selectedHost);
if (email) {
webClient.request.authentication.resetPasswordChallenge({ userName, email, host, port });
} else {
setUserToResetPassword(userName);
webClient.request.authentication.resetPasswordRequest({ userName, host, port });
}
};
const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }: any) => {
const { host, port } = getHostPort(selectedHost);
webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port });
};
const skipTokenRequest = (userName: string) => {
setUserToResetPassword(userName);
setDialogState((s) => ({
...s,
passwordResetRequestDialog: false,
resetPasswordDialog: true,
}));
};
return {
description,
isConnected,
pendingActivationOptions,
dialogState,
userToResetPassword,
submitButtonDisabled,
handleLogin,
showDescription,
handleRegistrationDialogSubmit,
handleAccountActivationDialogSubmit,
handleRequestPasswordResetDialogSubmit,
handleResetPasswordDialogSubmit,
skipTokenRequest,
closeRequestPasswordResetDialog,
openRequestPasswordResetDialog,
closeResetPasswordDialog,
closeRegistrationDialog,
openRegistrationDialog,
closeActivateAccountDialog,
};
}

View file

@ -1,5 +1,3 @@
import React from 'react';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
@ -12,6 +10,8 @@ import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import Typography from '@mui/material/Typography';
import { useLogResults } from './useLogResults';
import './LogResults.css';
const LogResults = (props) => {
@ -21,31 +21,15 @@ const LogResults = (props) => {
const hasGameLogs = logs.game && logs.game.length;
const hasChatLogs = logs.chat && logs.chat.length;
const [value, setValue] = React.useState(0);
const handleChange = (event, newValue) => {
setValue(newValue);
};
const { value, handleChange } = useLogResults();
const headerCells = [
{
label: 'Time'
},
{
label: 'Sender Name'
},
{
label: 'Sender IP'
},
{
label: 'Message'
},
{
label: 'Target ID'
},
{
label: 'Target Name'
}
{ label: 'Time' },
{ label: 'Sender Name' },
{ label: 'Sender IP' },
{ label: 'Message' },
{ label: 'Target ID' },
{ label: 'Target Name' },
];
return (
@ -67,7 +51,7 @@ const LogResults = (props) => {
<Results logs={logs.chat} headerCells={headerCells} />
</TabPanel>
</div>
)
);
};
const a11yProps = index => {
@ -97,13 +81,13 @@ const Results = ({ headerCells, logs }) => (
<Table size="small">
<TableHead>
<TableRow>
{ headerCells.map(({ label }) => (
{headerCells.map(({ label }) => (
<TableCell key={label}>{label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{ logs.map(({ time, senderName, senderIp, message, targetId, targetName }, index) => (
{logs.map(({ time, senderName, senderIp, message, targetId, targetName }, index) => (
<TableRow key={index}>
<TableCell>{time}</TableCell>
<TableCell>{senderName}</TableCell>

View file

@ -1,61 +1,13 @@
import { useEffect } from 'react';
import { AuthGuard, ModGuard } from '@app/components';
import { SearchForm } from '@app/forms';
import { useWebClient } from '@app/hooks';
import { ServerDispatch, ServerSelectors } from '@app/store';
import { Data } from '@app/types';
import { useAppSelector } from '@app/store';
import LogResults from './LogResults';
import { useLogs } from './useLogs';
import './Logs.css';
const Logs = () => {
const logs = useAppSelector(state => ServerSelectors.getLogs(state));
const webClient = useWebClient();
const MAXIMUM_RESULTS = 1000;
useEffect(() => {
return () => {
ServerDispatch.clearLogs();
};
}, []);
const trimFields = (fields) => {
const result: any = {};
for (const [key, field] of Object.entries(fields)) {
if (typeof field === 'string') {
const trimmed = field.trim();
if (trimmed) {
result[key] = trimmed;
}
} else {
result[key] = field;
}
}
return result;
};
const flattenLogLocations = (logLocations) => Object.keys(logLocations);
const onSubmit = (fields: Data.ViewLogHistoryParams) => {
const trimmedFields: any = trimFields(fields);
const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields;
const required = [userName, ipAddress, gameName, gameId, message].filter(Boolean);
if (logLocation) {
trimmedFields.logLocation = flattenLogLocations(logLocation);
}
trimmedFields.maximumResults = MAXIMUM_RESULTS;
if (required.length) {
webClient.request.moderator.viewLogHistory(trimmedFields);
} else {
// @TODO use yet-to-be-implemented banner/alert
}
};
const { logs, onSubmit } = useLogs();
return (
<div className="moderator-logs overflow-scroll">
@ -74,13 +26,3 @@ const Logs = () => {
};
export default Logs;

View file

@ -0,0 +1,16 @@
import { useState } from 'react';
export interface LogResults {
value: number;
handleChange: (event: unknown, newValue: number) => void;
}
export function useLogResults(): LogResults {
const [value, setValue] = useState(0);
const handleChange = (_event: unknown, newValue: number) => {
setValue(newValue);
};
return { value, handleChange };
}

View file

@ -0,0 +1,61 @@
import { useEffect } from 'react';
import { useWebClient } from '@app/hooks';
import { ServerDispatch, ServerSelectors, useAppSelector } from '@app/store';
import { Data } from '@app/types';
const MAXIMUM_RESULTS = 1000;
export interface Logs {
logs: any;
onSubmit: (fields: Data.ViewLogHistoryParams) => void;
}
export function useLogs(): Logs {
const logs = useAppSelector((state) => ServerSelectors.getLogs(state));
const webClient = useWebClient();
useEffect(() => {
return () => {
ServerDispatch.clearLogs();
};
}, []);
const trimFields = (fields: any) => {
const result: any = {};
for (const [key, field] of Object.entries(fields)) {
if (typeof field === 'string') {
const trimmed = field.trim();
if (trimmed) {
result[key] = trimmed;
}
} else {
result[key] = field;
}
}
return result;
};
const flattenLogLocations = (logLocations: any) => Object.keys(logLocations);
const onSubmit = (fields: Data.ViewLogHistoryParams) => {
const trimmedFields: any = trimFields(fields);
const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields;
const required = [userName, ipAddress, gameName, gameId, message].filter(Boolean);
if (logLocation) {
trimmedFields.logLocation = flattenLogLocations(logLocation);
}
trimmedFields.maximumResults = MAXIMUM_RESULTS;
if (required.length) {
webClient.request.moderator.viewLogHistory(trimmedFields);
} else {
// @TODO use yet-to-be-implemented banner/alert
}
};
return { logs, onSubmit };
}

View file

@ -1,4 +1,4 @@
import { fireEvent, screen } from '@testing-library/react';
import { act, fireEvent, screen } from '@testing-library/react';
import { create } from '@bufbuild/protobuf';
import {
renderWithProviders,
@ -241,12 +241,12 @@ describe('GameSelector', () => {
});
mockNavigate.mockClear();
store.dispatch({
type: GameTypes.GAME_JOINED,
payload: { data: { gameInfo: { gameId: 42 }, hostId: 0, playerId: 0, spectator: false } },
await act(async () => {
store.dispatch({
type: GameTypes.GAME_JOINED,
payload: { data: { gameInfo: { gameId: 42 }, hostId: 0, playerId: 0, spectator: false } },
});
});
await Promise.resolve();
expect(mockNavigate).toHaveBeenCalledWith(App.RouteEnum.GAME);
});

View file

@ -1,6 +1,3 @@
// eslint-disable-next-line
import React from "react";
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
@ -9,11 +6,9 @@ import TableRow from '@mui/material/TableRow';
import TableSortLabel from '@mui/material/TableSortLabel';
import Tooltip from '@mui/material/Tooltip';
// import { RoomsService } from "AppShell/common/services";
import { SortUtil, RoomsDispatch, RoomsSelectors } from '@app/store';
import { UserDisplay } from '@app/components';
import { useAppSelector } from '@app/store';
import { useGames } from './useGames';
import './Games.css';
@ -24,8 +19,7 @@ interface GamesProps {
const Games = ({ room }: GamesProps) => {
const roomId = room.info.roomId;
const sortBy = useAppSelector(state => RoomsSelectors.getSortGamesBy(state));
const sortedGames = useAppSelector(state => RoomsSelectors.getSortedRoomGames(state, roomId));
const { sortBy, games, handleSort } = useGames(roomId);
const headerCells = [
{ label: 'Age', field: 'info.startTime' },
@ -37,30 +31,12 @@ const Games = ({ room }: GamesProps) => {
{ label: 'Spectators', field: 'info.spectatorsCount' },
];
const handleSort = (sortByField) => {
const { field, order } = SortUtil.toggleSortBy(sortByField, 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 (
<div className="games">
<Table size="small">
<TableHead>
<TableRow>
{ headerCells.map(({ label, field }) => {
{headerCells.map(({ label, field }) => {
const active = field === sortBy.field;
const order = sortBy.order.toLowerCase();
const sortDirection = active ? (order === 'asc' ? 'asc' : 'desc') : false;
@ -82,7 +58,7 @@ const Games = ({ room }: GamesProps) => {
</TableRow>
</TableHead>
<TableBody>
{ games.map((game) => {
{games.map((game) => {
const { info, gameType } = game;
const { description, gameId, creatorInfo, maxPlayers, playerCount, spectatorsCount, startTime } = info;
return (
@ -96,7 +72,7 @@ const Games = ({ room }: GamesProps) => {
</Tooltip>
</TableCell>
<TableCell className="games-header__cell">
<UserDisplay user={ creatorInfo } />
<UserDisplay user={creatorInfo} />
</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{gameType}</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">?</TableCell>

View file

@ -1,5 +1,3 @@
import React from 'react';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
@ -8,11 +6,11 @@ import TableRow from '@mui/material/TableRow';
import TableSortLabel from '@mui/material/TableSortLabel';
import Tooltip from '@mui/material/Tooltip';
import { SortUtil, RoomsDispatch, RoomsSelectors } from '@app/store';
import { UserDisplay } from '@app/components';
import { useAppSelector } from '@app/store';
import { Data, Enriched } from '@app/types';
import { useOpenGames } from './useOpenGames';
import './OpenGames.css';
interface OpenGamesProps {
@ -56,9 +54,10 @@ function formatSpectators(info: Data.ServerInfo_Game): string {
const OpenGames = ({ room, onActivateGame }: OpenGamesProps) => {
const roomId = room.info.roomId;
const sortBy = useAppSelector(state => RoomsSelectors.getSortGamesBy(state));
const games = useAppSelector(state => RoomsSelectors.getFilteredRoomGames(state, roomId));
const selectedGameId = useAppSelector(state => RoomsSelectors.getSelectedGameId(state, roomId));
const { sortBy, games, selectedGameId, handleSort, handleSelect, handleActivate } = useOpenGames({
roomId,
onActivateGame,
});
const headerCells = [
{ label: 'Age', field: 'info.startTime' },
@ -70,26 +69,12 @@ const OpenGames = ({ room, onActivateGame }: OpenGamesProps) => {
{ label: 'Spectators', field: 'info.spectatorsCount' },
];
const handleSort = (sortByField) => {
const { field, order } = SortUtil.toggleSortBy(sortByField, sortBy);
RoomsDispatch.sortGames(roomId, field, order);
};
const handleSelect = (gameId: number) => {
RoomsDispatch.selectGame(roomId, gameId);
};
const handleActivate = (gameId: number) => {
RoomsDispatch.selectGame(roomId, gameId);
onActivateGame?.(gameId);
};
return (
<div className="games">
<Table size="small">
<TableHead>
<TableRow>
{ headerCells.map(({ label, field }) => {
{headerCells.map(({ label, field }) => {
const active = field === sortBy.field;
const order = sortBy.order.toLowerCase();
const sortDirection = active ? (order === 'asc' ? 'asc' : 'desc') : false;
@ -111,7 +96,7 @@ const OpenGames = ({ room, onActivateGame }: OpenGamesProps) => {
</TableRow>
</TableHead>
<TableBody>
{ games.map((game: Enriched.Game) => {
{games.map((game: Enriched.Game) => {
const { info, gameType } = game;
const { description, gameId, creatorInfo, maxPlayers, playerCount, startTime } = info;
const isSelected = gameId === selectedGameId;

View file

@ -1,51 +1,23 @@
import React, { useEffect } from 'react';
import { useNavigate, useParams, generatePath } from 'react-router-dom';
import ListItemButton from '@mui/material/ListItemButton';
import Paper from '@mui/material/Paper';
import { ScrollToBottomOnChanges, ThreePaneLayout, UserDisplay, VirtualList, AuthGuard } from '@app/components';
import { useWebClient } from '@app/hooks';
import { RoomsSelectors } from '@app/store';
import { useAppSelector } from '@app/store';
import { App } from '@app/types';
import Layout from '../Layout/Layout';
import GameSelector from './GameSelector/GameSelector';
import Messages from './Messages';
import SayMessage from './SayMessage';
import { useRoom } from './useRoom';
import './Room.css';
const Room = () => {
const joined = useAppSelector(state => RoomsSelectors.getJoinedRooms(state));
const rooms = useAppSelector(state => RoomsSelectors.getRooms(state));
const messages = useAppSelector(state => RoomsSelectors.getMessages(state));
const navigate = useNavigate();
const params = useParams();
const roomId = parseInt(params.roomId, 10);
const room = rooms[roomId];
const roomMessages = messages[roomId];
const users = useAppSelector(state => RoomsSelectors.getSortedRoomUsers(state, roomId));
const webClient = useWebClient();
useEffect(() => {
if (!joined.find(r => r.info.roomId === roomId)) {
navigate(generatePath(App.RouteEnum.SERVER));
}
}, [joined]);
const { room, roomMessages, users, handleRoomSay } = useRoom();
if (!room) {
return null;
}
const handleRoomSay = ({ message }) => {
if (message) {
webClient.request.rooms.roomSay(roomId, message);
}
}
return (
<Layout className="room-view">
<AuthGuard />
@ -76,15 +48,15 @@ const Room = () => {
side={(
<Paper className="room-view__side overflow-scroll">
<div className="room-view__side-label">
Users in this room: {users.length}
Users in this room: {users.length}
</div>
<VirtualList
className="room-view__side-list"
items={ users.map(user => (
items={users.map(user => (
<ListItemButton key={user.name} className="room-view__side-list__item">
<UserDisplay user={user} />
</ListItemButton>
)) }
))}
/>
</Paper>
)}
@ -92,6 +64,6 @@ const Room = () => {
</div>
</Layout>
);
}
};
export default Room;

View file

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

View file

@ -0,0 +1,38 @@
import { SortUtil, RoomsDispatch, RoomsSelectors, useAppSelector } from '@app/store';
import { App, Enriched } from '@app/types';
export interface OpenGames {
sortBy: { field: string; order: string };
games: Enriched.Game[];
selectedGameId: number | undefined;
handleSort: (sortByField: string) => void;
handleSelect: (gameId: number) => void;
handleActivate: (gameId: number) => void;
}
export interface UseOpenGamesArgs {
roomId: number;
onActivateGame?: (gameId: number) => void;
}
export function useOpenGames({ roomId, onActivateGame }: UseOpenGamesArgs): OpenGames {
const sortBy = useAppSelector((state) => RoomsSelectors.getSortGamesBy(state));
const games = useAppSelector((state) => RoomsSelectors.getFilteredRoomGames(state, roomId));
const selectedGameId = useAppSelector((state) => RoomsSelectors.getSelectedGameId(state, roomId));
const handleSort = (sortByField: string) => {
const { field, order } = SortUtil.toggleSortBy(sortByField as App.GameSortField, sortBy);
RoomsDispatch.sortGames(roomId, field, order);
};
const handleSelect = (gameId: number) => {
RoomsDispatch.selectGame(roomId, gameId);
};
const handleActivate = (gameId: number) => {
RoomsDispatch.selectGame(roomId, gameId);
onActivateGame?.(gameId);
};
return { sortBy, games, selectedGameId, handleSort, handleSelect, handleActivate };
}

View file

@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { useNavigate, useParams, generatePath } from 'react-router-dom';
import { useWebClient } from '@app/hooks';
import { RoomsSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
export interface Room {
roomId: number;
room: any;
roomMessages: any;
users: any[];
handleRoomSay: (args: { message: string }) => void;
}
export function useRoom(): Room {
const joined = useAppSelector((state) => RoomsSelectors.getJoinedRooms(state));
const rooms = useAppSelector((state) => RoomsSelectors.getRooms(state));
const messages = useAppSelector((state) => RoomsSelectors.getMessages(state));
const navigate = useNavigate();
const params = useParams();
const roomId = parseInt(params.roomId, 10);
const room = rooms[roomId];
const roomMessages = messages[roomId];
const users = useAppSelector((state) => RoomsSelectors.getSortedRoomUsers(state, roomId));
const webClient = useWebClient();
useEffect(() => {
if (!joined.find((r) => r.info.roomId === roomId)) {
navigate(generatePath(App.RouteEnum.SERVER));
}
}, [joined]);
const handleRoomSay = ({ message }: { message: string }) => {
if (message) {
webClient.request.rooms.roomSay(roomId, message);
}
};
return { roomId, room, roomMessages, users, handleRoomSay };
}

View file

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { styled } from '@mui/material/styles';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
@ -11,6 +10,8 @@ import TextField from '@mui/material/TextField';
import { App } from '@app/types';
import { cx } from '@app/utils';
import { useCreateCounterDialog } from './useCreateCounterDialog';
import './CreateCounterDialog.css';
const PREFIX = 'CreateCounterDialog';
@ -58,26 +59,8 @@ const SWATCHES: ReadonlyArray<Swatch> = [
];
function CreateCounterDialog({ isOpen, onSubmit, onCancel }: CreateCounterDialogProps) {
const [name, setName] = useState('');
const [selectedIdx, setSelectedIdx] = useState(0);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setName('');
setSelectedIdx(0);
setError(null);
}
}, [isOpen]);
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
if (name.trim().length === 0) {
setError('Name is required');
return;
}
onSubmit({ name: name.trim(), color: SWATCHES[selectedIdx].color });
};
const { name, selectedIdx, error, handleNameChange, setSelectedIdx, handleSubmit } =
useCreateCounterDialog({ isOpen, swatches: SWATCHES, onSubmit });
return (
<StyledDialog
@ -100,12 +83,7 @@ function CreateCounterDialog({ isOpen, onSubmit, onCancel }: CreateCounterDialog
size="small"
label="Counter name"
value={name}
onChange={(e) => {
setName(e.target.value);
if (error) {
setError(null);
}
}}
onChange={(e) => handleNameChange(e.target.value)}
error={error != null}
helperText={error ?? ''}
slotProps={{ htmlInput: { 'aria-label': 'Counter name' } }}

View file

@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
import type { CounterColor } from './CreateCounterDialog';
export interface CreateCounterDialogState {
name: string;
selectedIdx: number;
error: string | null;
handleNameChange: (value: string) => void;
setSelectedIdx: (idx: number) => void;
handleSubmit: (e?: React.FormEvent<HTMLFormElement>) => void;
}
export interface UseCreateCounterDialogArgs {
isOpen: boolean;
swatches: ReadonlyArray<{ color: CounterColor }>;
onSubmit: (args: { name: string; color: CounterColor }) => void;
}
export function useCreateCounterDialog({
isOpen,
swatches,
onSubmit,
}: UseCreateCounterDialogArgs): CreateCounterDialogState {
const [name, setName] = useState('');
const [selectedIdx, setSelectedIdx] = useState(0);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setName('');
setSelectedIdx(0);
setError(null);
}
}, [isOpen]);
const handleNameChange = (value: string) => {
setName(value);
if (error) {
setError(null);
}
};
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
if (name.trim().length === 0) {
setError('Name is required');
return;
}
onSubmit({ name: name.trim(), color: swatches[selectedIdx].color });
};
return { name, selectedIdx, error, handleNameChange, setSelectedIdx, handleSubmit };
}

View file

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { styled } from '@mui/material/styles';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
@ -14,6 +13,13 @@ import MenuItem from '@mui/material/MenuItem';
import InputLabel from '@mui/material/InputLabel';
import FormControl from '@mui/material/FormControl';
import {
MAX_ANNOTATION_LEN,
MAX_NAME_LEN,
MAX_PT_LEN,
useCreateTokenDialog,
} from './useCreateTokenDialog';
import './CreateTokenDialog.css';
const PREFIX = 'CreateTokenDialog';
@ -58,51 +64,23 @@ const COLOR_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
{ value: '', label: 'Colorless' },
];
const DEFAULT_COLOR = 'w';
// Desktop server-side MAX_NAME_LENGTH is 0xff (255). Client-side caps on
// the other free-text fields mirror that to prevent oversize-payload
// round-trips that the server would reject anyway.
const MAX_NAME_LEN = 255;
const MAX_PT_LEN = 255;
const MAX_ANNOTATION_LEN = 255;
function CreateTokenDialog({ isOpen, onSubmit, onCancel }: CreateTokenDialogProps) {
const [name, setName] = useState('');
const [color, setColor] = useState(DEFAULT_COLOR);
const [pt, setPT] = useState('');
const [annotation, setAnnotation] = useState('');
const [destroyOnZoneChange, setDestroyOnZoneChange] = useState(true);
const [faceDown, setFaceDown] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setName('');
setColor(DEFAULT_COLOR);
setPT('');
setAnnotation('');
setDestroyOnZoneChange(true);
setFaceDown(false);
setError(null);
}
}, [isOpen]);
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
if (name.trim().length === 0) {
setError('Name is required');
return;
}
onSubmit({
name: name.trim(),
color,
pt: pt.trim(),
annotation: annotation.trim(),
destroyOnZoneChange,
faceDown,
});
};
const {
name,
color,
pt,
annotation,
destroyOnZoneChange,
faceDown,
error,
handleNameChange,
setColor,
setPT,
setAnnotation,
setDestroyOnZoneChange,
setFaceDown,
handleSubmit,
} = useCreateTokenDialog({ isOpen, onSubmit });
return (
<StyledDialog
@ -125,12 +103,7 @@ function CreateTokenDialog({ isOpen, onSubmit, onCancel }: CreateTokenDialogProp
size="small"
label="Token name"
value={name}
onChange={(e) => {
setName(e.target.value.slice(0, MAX_NAME_LEN));
if (error) {
setError(null);
}
}}
onChange={(e) => handleNameChange(e.target.value)}
error={error != null}
helperText={error ?? ''}
slotProps={{ htmlInput: { 'aria-label': 'Token name', maxLength: MAX_NAME_LEN } }}

View file

@ -0,0 +1,99 @@
import { useEffect, useState } from 'react';
import type { CreateTokenSubmit } from './CreateTokenDialog';
export interface CreateTokenDialogState {
name: string;
color: string;
pt: string;
annotation: string;
destroyOnZoneChange: boolean;
faceDown: boolean;
error: string | null;
handleNameChange: (value: string) => void;
setColor: (value: string) => void;
setPT: (value: string) => void;
setAnnotation: (value: string) => void;
setDestroyOnZoneChange: (value: boolean) => void;
setFaceDown: (value: boolean) => void;
handleSubmit: (e?: React.FormEvent<HTMLFormElement>) => void;
}
export const CREATE_TOKEN_DEFAULT_COLOR = 'w';
// Desktop server-side MAX_NAME_LENGTH is 0xff (255). Client-side caps on
// the other free-text fields mirror that to prevent oversize-payload
// round-trips that the server would reject anyway.
export const MAX_NAME_LEN = 255;
export const MAX_PT_LEN = 255;
export const MAX_ANNOTATION_LEN = 255;
export interface UseCreateTokenDialogArgs {
isOpen: boolean;
onSubmit: (args: CreateTokenSubmit) => void;
}
export function useCreateTokenDialog({
isOpen,
onSubmit,
}: UseCreateTokenDialogArgs): CreateTokenDialogState {
const [name, setName] = useState('');
const [color, setColor] = useState(CREATE_TOKEN_DEFAULT_COLOR);
const [pt, setPT] = useState('');
const [annotation, setAnnotation] = useState('');
const [destroyOnZoneChange, setDestroyOnZoneChange] = useState(true);
const [faceDown, setFaceDown] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setName('');
setColor(CREATE_TOKEN_DEFAULT_COLOR);
setPT('');
setAnnotation('');
setDestroyOnZoneChange(true);
setFaceDown(false);
setError(null);
}
}, [isOpen]);
const handleNameChange = (value: string) => {
setName(value.slice(0, MAX_NAME_LEN));
if (error) {
setError(null);
}
};
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
if (name.trim().length === 0) {
setError('Name is required');
return;
}
onSubmit({
name: name.trim(),
color,
pt: pt.trim(),
annotation: annotation.trim(),
destroyOnZoneChange,
faceDown,
});
};
return {
name,
color,
pt,
annotation,
destroyOnZoneChange,
faceDown,
error,
handleNameChange,
setColor,
setPT,
setAnnotation,
setDestroyOnZoneChange,
setFaceDown,
handleSubmit,
};
}

View file

@ -1,4 +1,3 @@
import { useState } from 'react';
import { styled } from '@mui/material/styles';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
@ -6,8 +5,7 @@ import DialogTitle from '@mui/material/DialogTitle';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { useDeckSelectDialog } from './useDeckSelectDialog';
import './DeckSelectDialog.css';
@ -40,34 +38,16 @@ export interface DeckSelectDialogProps {
}
function DeckSelectDialog({ isOpen, gameId, handleClose }: DeckSelectDialogProps) {
const webClient = useWebClient();
const localPlayer = useAppSelector((state) =>
gameId != null ? GameSelectors.getLocalPlayer(state, gameId) : undefined,
);
const [deckText, setDeckText] = useState('');
const deckHash = localPlayer?.properties.deckHash ?? '';
const isReady = localPlayer?.properties.readyStart ?? false;
const hasLocalPlayer = localPlayer != null;
// Guard Submit/Ready on having a local player — today the deckSelectOpen
// predicate in Game.tsx implies one, but the dialog mounts before the
// Event_GameJoined echo populates players during reconnect.
const canSubmit = hasLocalPlayer && deckText.trim().length > 0;
const canToggleReady = hasLocalPlayer && deckHash.length > 0;
const handleSubmitDeck = () => {
if (!canSubmit || gameId == null) {
return;
}
webClient.request.game.deckSelect(gameId, { deck: deckText.trim() });
};
const handleToggleReady = () => {
if (!canToggleReady || gameId == null) {
return;
}
webClient.request.game.readyStart(gameId, { ready: !isReady });
};
const {
deckText,
setDeckText,
deckHash,
isReady,
canSubmit,
canToggleReady,
handleSubmitDeck,
handleToggleReady,
} = useDeckSelectDialog(gameId);
return (
<StyledDialog

View file

@ -0,0 +1,57 @@
import { useState } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
export interface DeckSelectDialog {
deckText: string;
setDeckText: (v: string) => void;
deckHash: string;
isReady: boolean;
canSubmit: boolean;
canToggleReady: boolean;
handleSubmitDeck: () => void;
handleToggleReady: () => void;
}
export function useDeckSelectDialog(gameId: number | undefined): DeckSelectDialog {
const webClient = useWebClient();
const localPlayer = useAppSelector((state) =>
gameId != null ? GameSelectors.getLocalPlayer(state, gameId) : undefined,
);
const [deckText, setDeckText] = useState('');
const deckHash = localPlayer?.properties.deckHash ?? '';
const isReady = localPlayer?.properties.readyStart ?? false;
const hasLocalPlayer = localPlayer != null;
// Guard Submit/Ready on having a local player — today the deckSelectOpen
// predicate in Game.tsx implies one, but the dialog mounts before the
// Event_GameJoined echo populates players during reconnect.
const canSubmit = hasLocalPlayer && deckText.trim().length > 0;
const canToggleReady = hasLocalPlayer && deckHash.length > 0;
const handleSubmitDeck = () => {
if (!canSubmit || gameId == null) {
return;
}
webClient.request.game.deckSelect(gameId, { deck: deckText.trim() });
};
const handleToggleReady = () => {
if (!canToggleReady || gameId == null) {
return;
}
webClient.request.game.readyStart(gameId, { ready: !isReady });
};
return {
deckText,
setDeckText,
deckHash,
isReady,
canSubmit,
canToggleReady,
handleSubmitDeck,
handleToggleReady,
};
}

View file

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { styled } from '@mui/material/styles';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
@ -8,6 +7,8 @@ import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import { usePromptDialog } from './usePromptDialog';
import './PromptDialog.css';
const PREFIX = 'PromptDialog';
@ -48,27 +49,12 @@ function PromptDialog({
onSubmit,
onCancel,
}: PromptDialogProps) {
const [value, setValue] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setValue(initialValue);
setError(null);
}
}, [isOpen, initialValue]);
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
if (validate) {
const message = validate(value);
if (message) {
setError(message);
return;
}
}
onSubmit(value);
};
const { value, error, handleChange, handleSubmit } = usePromptDialog({
isOpen,
initialValue,
validate,
onSubmit,
});
return (
<StyledDialog
@ -91,12 +77,7 @@ function PromptDialog({
size="small"
label={label}
value={value}
onChange={(e) => {
setValue(e.target.value);
if (error) {
setError(null);
}
}}
onChange={(e) => handleChange(e.target.value)}
error={error != null}
helperText={error ?? helperText ?? ''}
slotProps={{ htmlInput: { 'aria-label': label } }}

View file

@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
export interface PromptDialog {
value: string;
error: string | null;
handleChange: (v: string) => void;
handleSubmit: (e?: React.FormEvent<HTMLFormElement>) => void;
}
export interface UsePromptDialogArgs {
isOpen: boolean;
initialValue: string;
validate?: (value: string) => string | null;
onSubmit: (value: string) => void;
}
export function usePromptDialog({
isOpen,
initialValue,
validate,
onSubmit,
}: UsePromptDialogArgs): PromptDialog {
const [value, setValue] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setValue(initialValue);
setError(null);
}
}, [isOpen, initialValue]);
const handleChange = (v: string) => {
setValue(v);
if (error) {
setError(null);
}
};
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
if (validate) {
const message = validate(value);
if (message) {
setError(message);
return;
}
}
onSubmit(value);
};
return { value, error, handleChange, handleSubmit };
}

View file

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { styled } from '@mui/material/styles';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
@ -12,6 +11,8 @@ import MenuItem from '@mui/material/MenuItem';
import InputLabel from '@mui/material/InputLabel';
import FormControl from '@mui/material/FormControl';
import { useRevealCardsDialog } from './useRevealCardsDialog';
import './RevealCardsDialog.css';
const PREFIX = 'RevealCardsDialog';
@ -59,31 +60,14 @@ function RevealCardsDialog({
onSubmit,
onCancel,
}: RevealCardsDialogProps) {
const [targetPlayerId, setTargetPlayerId] = useState<number>(ALL_PLAYERS);
const [countDraft, setCountDraft] = useState(String(defaultCount));
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setTargetPlayerId(ALL_PLAYERS);
setCountDraft(String(defaultCount));
setError(null);
}
}, [isOpen, defaultCount]);
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
let topCards = -1;
if (showCountInput) {
const n = Number(countDraft);
if (!Number.isInteger(n) || n < 1) {
setError('Enter a positive integer');
return;
}
topCards = n;
}
onSubmit({ targetPlayerId, topCards });
};
const {
targetPlayerId,
countDraft,
error,
setTargetPlayerId,
handleCountChange,
handleSubmit,
} = useRevealCardsDialog({ isOpen, showCountInput, defaultCount, onSubmit });
return (
<StyledDialog
@ -128,12 +112,7 @@ function RevealCardsDialog({
type="number"
label="How many?"
value={countDraft}
onChange={(e) => {
setCountDraft(e.target.value);
if (error) {
setError(null);
}
}}
onChange={(e) => handleCountChange(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
error={error != null}
helperText={error ?? 'Enter a positive integer'}

View file

@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import type { RevealCardsSubmit } from './RevealCardsDialog';
export interface RevealCardsDialogState {
targetPlayerId: number;
countDraft: string;
error: string | null;
setTargetPlayerId: (id: number) => void;
handleCountChange: (value: string) => void;
handleSubmit: (e?: React.FormEvent<HTMLFormElement>) => void;
}
export interface UseRevealCardsDialogArgs {
isOpen: boolean;
showCountInput: boolean;
defaultCount: number;
onSubmit: (args: RevealCardsSubmit) => void;
}
const ALL_PLAYERS = -1;
export function useRevealCardsDialog({
isOpen,
showCountInput,
defaultCount,
onSubmit,
}: UseRevealCardsDialogArgs): RevealCardsDialogState {
const [targetPlayerId, setTargetPlayerId] = useState<number>(ALL_PLAYERS);
const [countDraft, setCountDraft] = useState(String(defaultCount));
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setTargetPlayerId(ALL_PLAYERS);
setCountDraft(String(defaultCount));
setError(null);
}
}, [isOpen, defaultCount]);
const handleCountChange = (value: string) => {
setCountDraft(value);
if (error) {
setError(null);
}
};
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
let topCards = -1;
if (showCountInput) {
const n = Number(countDraft);
if (!Number.isInteger(n) || n < 1) {
setError('Enter a positive integer');
return;
}
topCards = n;
}
onSubmit({ targetPlayerId, topCards });
};
return {
targetPlayerId,
countDraft,
error,
setTargetPlayerId,
handleCountChange,
handleSubmit,
};
}

View file

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { styled } from '@mui/material/styles';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
@ -8,6 +7,8 @@ import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import { useRollDieDialog } from './useRollDieDialog';
import './RollDieDialog.css';
const PREFIX = 'RollDieDialog';
@ -43,32 +44,8 @@ function RollDieDialog({
onSubmit,
onCancel,
}: RollDieDialogProps) {
const [sides, setSides] = useState(String(lastSides));
const [count, setCount] = useState(String(lastCount));
const [error, setError] = useState<{ field: 'sides' | 'count'; message: string } | null>(null);
useEffect(() => {
if (isOpen) {
setSides(String(lastSides));
setCount(String(lastCount));
setError(null);
}
}, [isOpen, lastSides, lastCount]);
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
const s = Number(sides);
if (!Number.isInteger(s) || s < 1) {
setError({ field: 'sides', message: 'Enter an integer ≥ 1' });
return;
}
const c = Number(count);
if (!Number.isInteger(c) || c < 1) {
setError({ field: 'count', message: 'Enter an integer ≥ 1' });
return;
}
onSubmit({ sides: s, count: c });
};
const { sides, count, error, handleSidesChange, handleCountChange, handleSubmit } =
useRollDieDialog({ isOpen, lastSides, lastCount, onSubmit });
return (
<StyledDialog
@ -91,12 +68,7 @@ function RollDieDialog({
size="small"
label="Sides"
value={sides}
onChange={(e) => {
setSides(e.target.value);
if (error) {
setError(null);
}
}}
onChange={(e) => handleSidesChange(e.target.value)}
error={error?.field === 'sides'}
helperText={error?.field === 'sides' ? error.message : ''}
slotProps={{ htmlInput: { 'aria-label': 'Sides', inputMode: 'numeric' } }}
@ -107,12 +79,7 @@ function RollDieDialog({
size="small"
label="Count"
value={count}
onChange={(e) => {
setCount(e.target.value);
if (error) {
setError(null);
}
}}
onChange={(e) => handleCountChange(e.target.value)}
error={error?.field === 'count'}
helperText={error?.field === 'count' ? error.message : ''}
slotProps={{ htmlInput: { 'aria-label': 'Count', inputMode: 'numeric' } }}

View file

@ -0,0 +1,67 @@
import { useEffect, useState } from 'react';
export interface RollDieDialogState {
sides: string;
count: string;
error: { field: 'sides' | 'count'; message: string } | null;
handleSidesChange: (value: string) => void;
handleCountChange: (value: string) => void;
handleSubmit: (e?: React.FormEvent<HTMLFormElement>) => void;
}
export interface UseRollDieDialogArgs {
isOpen: boolean;
lastSides: number;
lastCount: number;
onSubmit: (args: { sides: number; count: number }) => void;
}
export function useRollDieDialog({
isOpen,
lastSides,
lastCount,
onSubmit,
}: UseRollDieDialogArgs): RollDieDialogState {
const [sides, setSides] = useState(String(lastSides));
const [count, setCount] = useState(String(lastCount));
const [error, setError] = useState<{ field: 'sides' | 'count'; message: string } | null>(null);
useEffect(() => {
if (isOpen) {
setSides(String(lastSides));
setCount(String(lastCount));
setError(null);
}
}, [isOpen, lastSides, lastCount]);
const handleSidesChange = (value: string) => {
setSides(value);
if (error) {
setError(null);
}
};
const handleCountChange = (value: string) => {
setCount(value);
if (error) {
setError(null);
}
};
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
const s = Number(sides);
if (!Number.isInteger(s) || s < 1) {
setError({ field: 'sides', message: 'Enter an integer ≥ 1' });
return;
}
const c = Number(count);
if (!Number.isInteger(c) || c < 1) {
setError({ field: 'count', message: 'Enter an integer ≥ 1' });
return;
}
onSubmit({ sides: s, count: c });
};
return { sides, count, error, handleSidesChange, handleCountChange, handleSubmit };
}

View file

@ -1,14 +1,12 @@
import { useRef, useState } from 'react';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import { useScryfallCard } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import type { Data } from '@app/types';
import './ZoneViewDialog.css';
import { useZoneViewDialog } from './useZoneViewDialog';
const EMPTY_CARDS: Data.ServerInfo_Card[] = [];
import './ZoneViewDialog.css';
export interface ZoneViewDialogProps {
isOpen: boolean;
@ -19,19 +17,6 @@ export interface ZoneViewDialogProps {
initialPosition?: { x: number; y: number };
}
function zoneLabel(zoneName: string | undefined): string {
switch (zoneName) {
case 'grave': return 'Graveyard';
case 'rfg': return 'Exile';
case 'deck': return 'Library';
case 'sb': return 'Sideboard';
case 'stack': return 'Stack';
case 'hand': return 'Hand';
case 'table': return 'Battlefield';
default: return zoneName ?? '';
}
}
function ZoneThumbnail({ card }: { card: Data.ServerInfo_Card }) {
const { smallUrl } = useScryfallCard(card);
return (
@ -57,71 +42,8 @@ function ZoneViewDialog({
handleClose,
initialPosition = DEFAULT_POSITION,
}: ZoneViewDialogProps) {
const cards = useAppSelector((state) =>
gameId != null && playerId != null && zoneName != null
? GameSelectors.getCards(state, gameId, playerId, zoneName)
: EMPTY_CARDS,
);
const zone = useAppSelector((state) =>
gameId != null && playerId != null && zoneName != null
? GameSelectors.getZone(state, gameId, playerId, zoneName)
: undefined,
);
const playerName = useAppSelector((state) => {
if (gameId == null || playerId == null) {
return undefined;
}
return GameSelectors.getPlayer(state, gameId, playerId)?.properties.userInfo?.name;
});
const count = zone?.cardCount ?? cards.length;
const title = `${playerName ?? ''} ${zoneLabel(zoneName)} (${count})`.trim();
// initialPosition is a caller-provided spawn point; we only honor it on mount.
// Later rerenders of the parent must not clobber a user's drag-positioned panel.
const [position, setPosition] = useState(initialPosition);
const dragStateRef = useRef<{
pointerId: number;
originX: number;
originY: number;
panelX: number;
panelY: number;
} | null>(null);
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) {
return;
}
const target = e.currentTarget;
target.setPointerCapture(e.pointerId);
dragStateRef.current = {
pointerId: e.pointerId,
originX: e.clientX,
originY: e.clientY,
panelX: position.x,
panelY: position.y,
};
};
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const drag = dragStateRef.current;
if (!drag || e.pointerId !== drag.pointerId) {
return;
}
setPosition({
x: drag.panelX + (e.clientX - drag.originX),
y: drag.panelY + (e.clientY - drag.originY),
});
};
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
const drag = dragStateRef.current;
if (!drag || e.pointerId !== drag.pointerId) {
return;
}
e.currentTarget.releasePointerCapture(e.pointerId);
dragStateRef.current = null;
};
const { cards, count, title, position, handlePointerDown, handlePointerMove, handlePointerUp } =
useZoneViewDialog({ gameId, playerId, zoneName, initialPosition });
if (!isOpen) {
return null;

View file

@ -0,0 +1,111 @@
import { useRef, useState } from 'react';
import { GameSelectors, useAppSelector } from '@app/store';
import type { Data } from '@app/types';
const EMPTY_CARDS: Data.ServerInfo_Card[] = [];
export interface ZoneViewDialog {
cards: Data.ServerInfo_Card[];
count: number;
title: string;
position: { x: number; y: number };
handlePointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
handlePointerMove: (e: React.PointerEvent<HTMLDivElement>) => void;
handlePointerUp: (e: React.PointerEvent<HTMLDivElement>) => void;
}
export interface UseZoneViewDialogArgs {
gameId: number | undefined;
playerId: number | undefined;
zoneName: string | undefined;
initialPosition: { x: number; y: number };
}
export function zoneLabel(zoneName: string | undefined): string {
switch (zoneName) {
case 'grave': return 'Graveyard';
case 'rfg': return 'Exile';
case 'deck': return 'Library';
case 'sb': return 'Sideboard';
case 'stack': return 'Stack';
case 'hand': return 'Hand';
case 'table': return 'Battlefield';
default: return zoneName ?? '';
}
}
export function useZoneViewDialog({
gameId,
playerId,
zoneName,
initialPosition,
}: UseZoneViewDialogArgs): ZoneViewDialog {
const cards = useAppSelector((state) =>
gameId != null && playerId != null && zoneName != null
? GameSelectors.getCards(state, gameId, playerId, zoneName)
: EMPTY_CARDS,
);
const zone = useAppSelector((state) =>
gameId != null && playerId != null && zoneName != null
? GameSelectors.getZone(state, gameId, playerId, zoneName)
: undefined,
);
const playerName = useAppSelector((state) => {
if (gameId == null || playerId == null) {
return undefined;
}
return GameSelectors.getPlayer(state, gameId, playerId)?.properties.userInfo?.name;
});
const count = zone?.cardCount ?? cards.length;
const title = `${playerName ?? ''} ${zoneLabel(zoneName)} (${count})`.trim();
// initialPosition is a caller-provided spawn point; we only honor it on mount.
// Later rerenders of the parent must not clobber a user's drag-positioned panel.
const [position, setPosition] = useState(initialPosition);
const dragStateRef = useRef<{
pointerId: number;
originX: number;
originY: number;
panelX: number;
panelY: number;
} | null>(null);
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) {
return;
}
const target = e.currentTarget;
target.setPointerCapture(e.pointerId);
dragStateRef.current = {
pointerId: e.pointerId,
originX: e.clientX,
originY: e.clientY,
panelX: position.x,
panelY: position.y,
};
};
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const drag = dragStateRef.current;
if (!drag || e.pointerId !== drag.pointerId) {
return;
}
setPosition({
x: drag.panelX + (e.clientX - drag.originX),
y: drag.panelY + (e.clientY - drag.originY),
});
};
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
const drag = dragStateRef.current;
if (!drag || e.pointerId !== drag.pointerId) {
return;
}
e.currentTarget.releasePointerCapture(e.pointerId);
dragStateRef.current = null;
};
return { cards, count, title, position, handlePointerDown, handlePointerMove, handlePointerUp };
}

View file

@ -1,5 +1,3 @@
import React, { useEffect, useState } from 'react';
import { Form, Field } from 'react-final-form';
import Button from '@mui/material/Button';
@ -9,76 +7,26 @@ import StepLabel from '@mui/material/StepLabel';
import CircularProgress from '@mui/material/CircularProgress';
import { InputField, VirtualList } from '@app/components';
import { cardImporterService, CardDTO, SetDTO, TokenDTO } from '@app/services';
import { useCardImportForm } from './useCardImportForm';
import './CardImportForm.css';
const CardImportForm = ({ onSubmit: onClose }) => {
const [loading, setLoading] = useState(false);
const [activeStep, setActiveStep] = useState(0);
const [importedCards, setImportedCards] = useState([]);
const [importedSets, setImportedSets] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
if (loading) {
setError(null);
}
}, [loading])
const {
loading,
activeStep,
importedCards,
importedSets,
error,
handleBack,
handleCardDownload,
handleCardSave,
handleTokenDownload,
} = useCardImportForm();
const steps = ['Imports sets', 'Save sets', 'Import tokens', 'Finished'];
const handleNext = () => {
setActiveStep((prevActiveStep) => prevActiveStep + 1);
};
const handleBack = () => {
setActiveStep((prevActiveStep) => prevActiveStep - 1);
};
const handleCardDownload = ({ cardDownloadUrl }) => {
setLoading(true);
cardImporterService.importCards(cardDownloadUrl)
.then(({ cards, sets }) => {
setImportedCards(cards);
setImportedSets(sets);
handleNext();
})
.catch(({ message }) => setError(message))
.finally(() => setLoading(false));
};
const handleCardSave = async () => {
setLoading(true);
try {
await CardDTO.bulkAdd(importedCards);
await SetDTO.bulkAdd(importedSets);
handleNext();
} catch (e) {
console.error(e);
setError('Failed to save cards');
}
setLoading(false);
};
const handleTokenDownload = ({ tokenDownloadUrl }) => {
setLoading(true);
cardImporterService.importTokens(tokenDownloadUrl)
.then(async tokens => {
await TokenDTO.bulkAdd(tokens);
handleNext();
})
.catch(({ message }) => setError(message))
.finally(() => setLoading(false));
};
const getStepContent = (stepIndex) => {
switch (stepIndex) {
case 0: return (
@ -175,14 +123,14 @@ const CardImportForm = ({ onSubmit: onClose }) => {
</Stepper>
<div>
{ getStepContent(activeStep) }
{getStepContent(activeStep)}
</div>
{ loading && (
{loading && (
<div className='loading'>
<CircularProgress size={60} />
</div>
) }
)}
</div>
);
};

View file

@ -0,0 +1,93 @@
import { useEffect, useState } from 'react';
import { cardImporterService, CardDTO, SetDTO, TokenDTO } from '@app/services';
export interface CardImportForm {
loading: boolean;
activeStep: number;
importedCards: any[];
importedSets: any[];
error: string | null;
handleNext: () => void;
handleBack: () => void;
handleCardDownload: (args: { cardDownloadUrl: string }) => void;
handleCardSave: () => Promise<void>;
handleTokenDownload: (args: { tokenDownloadUrl: string }) => void;
}
export function useCardImportForm(): CardImportForm {
const [loading, setLoading] = useState(false);
const [activeStep, setActiveStep] = useState(0);
const [importedCards, setImportedCards] = useState<any[]>([]);
const [importedSets, setImportedSets] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (loading) {
setError(null);
}
}, [loading]);
const handleNext = () => {
setActiveStep((prevActiveStep) => prevActiveStep + 1);
};
const handleBack = () => {
setActiveStep((prevActiveStep) => prevActiveStep - 1);
};
const handleCardDownload = ({ cardDownloadUrl }: { cardDownloadUrl: string }) => {
setLoading(true);
cardImporterService.importCards(cardDownloadUrl)
.then(({ cards, sets }) => {
setImportedCards(cards);
setImportedSets(sets);
handleNext();
})
.catch(({ message }) => setError(message))
.finally(() => setLoading(false));
};
const handleCardSave = async () => {
setLoading(true);
try {
await CardDTO.bulkAdd(importedCards);
await SetDTO.bulkAdd(importedSets);
handleNext();
} catch (e) {
console.error(e);
setError('Failed to save cards');
}
setLoading(false);
};
const handleTokenDownload = ({ tokenDownloadUrl }: { tokenDownloadUrl: string }) => {
setLoading(true);
cardImporterService.importTokens(tokenDownloadUrl)
.then(async (tokens) => {
await TokenDTO.bulkAdd(tokens);
handleNext();
})
.catch(({ message }) => setError(message))
.finally(() => setLoading(false));
};
return {
loading,
activeStep,
importedCards,
importedSets,
error,
handleNext,
handleBack,
handleCardDownload,
handleCardSave,
handleTokenDownload,
};
}

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Form, Field, useFormState, FormApi } from 'react-final-form';
import React from 'react';
import { Form, Field, FormApi } from 'react-final-form';
import { OnChange } from 'react-final-form-listeners';
import { useTranslation } from 'react-i18next';
@ -9,7 +9,8 @@ import FormControlLabel from '@mui/material/FormControlLabel';
import { CheckboxField, InputField, KnownHosts } from '@app/components';
import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
import { HostDTO } from '@app/services';
import { useLoginFormBody } from './useLoginForm';
import './LoginForm.css';
@ -34,61 +35,15 @@ const LoginFormBody = ({
const PASSWORD_LABEL = t('Common.label.password');
const STORED_PASSWORD_LABEL = `* ${t('LoginForm.label.savedPassword')} *`;
const settings = useSettings();
const hosts = useKnownHosts();
const { values } = useFormState();
const selectedHost = hosts.status === LoadingState.READY ? hosts.value?.selectedHost : undefined;
const [useStoredPasswordLabel, setUseStoredPasswordLabel] = useState(false);
const [storedHashInvalidated, setStoredHashInvalidated] = useState(false);
const canUseStoredPassword = (remember: boolean, password: string | undefined) =>
Boolean(remember && selectedHost?.hashedPassword && !password && !storedHashInvalidated);
const togglePasswordLabel = (on: boolean) => setUseStoredPasswordLabel(on);
// @critical Host-sync must not touch autoConnect — app-level setting, not per-host.
const onSelectedHostChange = (host: HostDTO | undefined) => {
if (!host) {
return;
}
form.change('userName', host.userName ?? '');
form.change('password', '');
form.change('remember', Boolean(host.remember));
setStoredHashInvalidated(false);
togglePasswordLabel(Boolean(host.remember && host.hashedPassword));
};
const onUserNameChange = (userName: string | undefined) => {
const fieldChanged = selectedHost?.userName?.toLowerCase() !== userName?.toLowerCase();
if (canUseStoredPassword(values.remember, values.password) && fieldChanged) {
setStoredHashInvalidated(true);
}
};
const onRememberChange = (checked: boolean) => {
// @critical Writes form-only, never to persisted setting — "remember" toggle isn't a preference edit.
if (!checked && values.autoConnect) {
form.change('autoConnect', false);
}
togglePasswordLabel(canUseStoredPassword(checked, values.password));
};
// @critical Only persist-path for autoConnect; wired to native onChange, not <OnChange>,
// to avoid leaking form.change() writes into Dexie.
const onUserToggleAutoConnect = (checked: boolean, fieldOnChange: (v: boolean) => void) => {
fieldOnChange(checked);
if (settings.status === LoadingState.READY) {
void settings.update({ autoConnect: checked });
}
if (checked && !values.remember) {
form.change('remember', true);
}
};
const {
useStoredPasswordLabel,
setUseStoredPasswordLabel,
onSelectedHostChange,
onUserNameChange,
onRememberChange,
onUserToggleAutoConnect,
passwordFieldBlur,
} = useLoginFormBody(form);
return (
<form className="loginForm" onSubmit={handleSubmit}>
@ -106,9 +61,7 @@ const LoginFormBody = ({
<Field
label={useStoredPasswordLabel ? STORED_PASSWORD_LABEL : PASSWORD_LABEL}
onFocus={() => setUseStoredPasswordLabel(false)}
onBlur={() =>
togglePasswordLabel(canUseStoredPassword(values.remember, values.password))
}
onBlur={passwordFieldBlur}
name="password"
type="password"
component={InputField}

View file

@ -0,0 +1,92 @@
import { useState } from 'react';
import { useFormState } from 'react-final-form';
import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
import { HostDTO } from '@app/services';
export interface LoginFormBody {
useStoredPasswordLabel: boolean;
setUseStoredPasswordLabel: (v: boolean) => void;
onSelectedHostChange: (host: HostDTO | undefined) => void;
onUserNameChange: (userName: string | undefined) => void;
onRememberChange: (checked: boolean) => void;
onUserToggleAutoConnect: (checked: boolean, fieldOnChange: (v: boolean) => void) => void;
passwordFieldBlur: () => void;
}
// `FormApi` import from react-final-form is broken at the type level on this
// branch (baseline TS error). Only `form.change` is used here.
interface MinimalFormApi {
change: (name: string, value: unknown) => void;
}
export function useLoginFormBody(form: MinimalFormApi): LoginFormBody {
const settings = useSettings();
const hosts = useKnownHosts();
const { values } = useFormState();
const selectedHost = hosts.status === LoadingState.READY ? hosts.value?.selectedHost : undefined;
const [useStoredPasswordLabel, setUseStoredPasswordLabel] = useState(false);
const [storedHashInvalidated, setStoredHashInvalidated] = useState(false);
const canUseStoredPassword = (remember: boolean, password: string | undefined) =>
Boolean(remember && selectedHost?.hashedPassword && !password && !storedHashInvalidated);
const togglePasswordLabel = (on: boolean) => setUseStoredPasswordLabel(on);
// @critical Host-sync must not touch autoConnect — app-level setting, not per-host.
const onSelectedHostChange = (host: HostDTO | undefined) => {
if (!host) {
return;
}
form.change('userName', host.userName ?? '');
form.change('password', '');
form.change('remember', Boolean(host.remember));
setStoredHashInvalidated(false);
togglePasswordLabel(Boolean(host.remember && host.hashedPassword));
};
const onUserNameChange = (userName: string | undefined) => {
const fieldChanged = selectedHost?.userName?.toLowerCase() !== userName?.toLowerCase();
if (canUseStoredPassword(values.remember, values.password) && fieldChanged) {
setStoredHashInvalidated(true);
}
};
const onRememberChange = (checked: boolean) => {
// @critical Writes form-only, never to persisted setting — "remember" toggle isn't a preference edit.
if (!checked && values.autoConnect) {
form.change('autoConnect', false);
}
togglePasswordLabel(canUseStoredPassword(checked, values.password));
};
// @critical Only persist-path for autoConnect; wired to native onChange, not <OnChange>,
// to avoid leaking form.change() writes into Dexie.
const onUserToggleAutoConnect = (checked: boolean, fieldOnChange: (v: boolean) => void) => {
fieldOnChange(checked);
if (settings.status === LoadingState.READY) {
void settings.update({ autoConnect: checked });
}
if (checked && !values.remember) {
form.change('remember', true);
}
};
const passwordFieldBlur = () =>
togglePasswordLabel(canUseStoredPassword(values.remember, values.password));
return {
useStoredPasswordLabel,
setUseStoredPasswordLabel,
onSelectedHostChange,
onUserNameChange,
onRememberChange,
onUserToggleAutoConnect,
passwordFieldBlur,
};
}

View file

@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { useEffect } from 'react';
import { Form, Field } from 'react-final-form';
import { OnChange } from 'react-final-form-listeners';
import setFieldTouched from 'final-form-set-field-touched';
@ -9,45 +8,25 @@ import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import { CountryDropdown, InputField, KnownHosts } from '@app/components';
import { useReduxEffect } from '@app/hooks';
import { ServerDispatch, ServerSelectors, ServerTypes } from '@app/store';
import { ServerDispatch } from '@app/store';
import { useRegisterForm } from './useRegisterForm';
import './RegisterForm.css';
import { useToast } from '@app/components';
const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
const { t } = useTranslation();
const [emailRequired, setEmailRequired] = useState(false);
const [emailError, setEmailError] = useState(null);
const [passwordError, setPasswordError] = useState(null);
const [userNameError, setUserNameError] = useState(null);
const error = useSelector(ServerSelectors.getRegistrationError);
const { openToast } = useToast({ key: 'registration-success', children: t('RegisterForm.toast.registerSuccess') })
const onHostChange = () => setEmailRequired(false);
const onEmailChange = () => emailError && setEmailError(null);
const onPasswordChange = () => passwordError && setPasswordError(null);
const onUserNameChange = () => userNameError && setUserNameError(null);
useReduxEffect(() => {
setEmailRequired(true);
}, ServerTypes.REGISTRATION_REQUIRES_EMAIL);
useReduxEffect(() => {
openToast()
}, ServerTypes.REGISTRATION_SUCCESS);
useReduxEffect(({ payload: { error } }) => {
setEmailError(error);
}, ServerTypes.REGISTRATION_EMAIL_ERROR);
useReduxEffect(({ payload: { error } }) => {
setPasswordError(error);
}, ServerTypes.REGISTRATION_PASSWORD_ERROR);
useReduxEffect(({ payload: { error } }) => {
setUserNameError(error);
}, ServerTypes.REGISTRATION_USERNAME_ERROR);
const {
emailRequired,
emailError,
passwordError,
userNameError,
error,
onHostChange,
onEmailChange,
onPasswordChange,
onUserNameChange,
} = useRegisterForm();
const handleOnSubmit = ({ userName, email, realName, ...values }) => {
ServerDispatch.clearRegistrationErrors();
@ -57,7 +36,7 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
realName = realName?.trim();
onSubmit({ userName, email, realName, ...values });
}
};
const validate = values => {
const errors: any = {};
@ -93,7 +72,7 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
}
return errors;
}
};
return (
<Form onSubmit={handleOnSubmit} validate={validate} mutators={{ setFieldTouched }}>
@ -149,12 +128,12 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
<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') }
{t('RegisterForm.label.register')}
</Button>
</div>
</form>
{ error && (
{error && (
<div className="RegisterForm-item">
<Typography color="error">{error}</Typography>
</div>

View file

@ -0,0 +1,69 @@
import { useState } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { useToast } from '@app/components';
import { useReduxEffect } from '@app/hooks';
import { ServerSelectors, ServerTypes } from '@app/store';
export interface RegisterForm {
emailRequired: boolean;
emailError: string | null;
passwordError: string | null;
userNameError: string | null;
error: string | null;
onHostChange: () => void;
onEmailChange: () => void;
onPasswordChange: () => void;
onUserNameChange: () => void;
}
export function useRegisterForm(): RegisterForm {
const { t } = useTranslation();
const [emailRequired, setEmailRequired] = useState(false);
const [emailError, setEmailError] = useState<string | null>(null);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [userNameError, setUserNameError] = useState<string | null>(null);
const error = useSelector(ServerSelectors.getRegistrationError);
const { openToast } = useToast({
key: 'registration-success',
children: t('RegisterForm.toast.registerSuccess'),
});
const onHostChange = () => setEmailRequired(false);
const onEmailChange = () => emailError && setEmailError(null);
const onPasswordChange = () => passwordError && setPasswordError(null);
const onUserNameChange = () => userNameError && setUserNameError(null);
useReduxEffect(() => {
setEmailRequired(true);
}, ServerTypes.REGISTRATION_REQUIRES_EMAIL);
useReduxEffect(() => {
openToast();
}, ServerTypes.REGISTRATION_SUCCESS);
useReduxEffect(({ payload: { error } }) => {
setEmailError(error);
}, ServerTypes.REGISTRATION_EMAIL_ERROR);
useReduxEffect(({ payload: { error } }) => {
setPasswordError(error);
}, ServerTypes.REGISTRATION_PASSWORD_ERROR);
useReduxEffect(({ payload: { error } }) => {
setUserNameError(error);
}, ServerTypes.REGISTRATION_USERNAME_ERROR);
return {
emailRequired,
emailError,
passwordError,
userNameError,
error,
onHostChange,
onEmailChange,
onPasswordChange,
onUserNameChange,
};
}

View file

@ -1,5 +1,3 @@
// eslint-disable-next-line
import React, { useState } from "react";
import { Form, Field } from 'react-final-form';
import { OnChange } from 'react-final-form-listeners';
import { useTranslation } from 'react-i18next';
@ -8,23 +6,14 @@ import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import { InputField, KnownHosts } from '@app/components';
import { useReduxEffect } from '@app/hooks';
import { ServerTypes } from '@app/store';
import { useRequestPasswordResetForm } from './useRequestPasswordResetForm';
import './RequestPasswordResetForm.css';
const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
const [errorMessage, setErrorMessage] = useState(false);
const [isMFA, setIsMFA] = useState(false);
const { t } = useTranslation();
useReduxEffect(() => {
setErrorMessage(true);
}, ServerTypes.RESET_PASSWORD_FAILED, []);
useReduxEffect(() => {
setIsMFA(true);
}, ServerTypes.RESET_PASSWORD_CHALLENGE, []);
const { errorMessage, setErrorMessage, isMFA, setIsMFA } = useRequestPasswordResetForm();
const handleOnSubmit = ({ userName, email, ...values }) => {
setErrorMessage(false);
@ -33,7 +22,7 @@ const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
email = email?.trim();
onSubmit({ userName, email, ...values });
}
};
const validate = values => {
const errors: any = {};
@ -57,7 +46,7 @@ const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
const onHostChange: any = ({ userName }) => {
form.change('userName', userName);
setIsMFA(false);
}
};
return (
<form className="RequestPasswordResetForm" onSubmit={handleSubmit}>
@ -68,7 +57,7 @@ const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
{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>{t('RequestPasswordResetForm.mfaEnabled')}</div>
</div>
) : null}
<div className="RequestPasswordResetForm-item selectedHost">
@ -78,18 +67,18 @@ const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
{errorMessage && (
<div className="RequestPasswordResetForm-item">
<Typography color="error">{ t('RequestPasswordResetForm.error') }</Typography>
<Typography color="error">{t('RequestPasswordResetForm.error')}</Typography>
</div>
)}
</div>
<Button className="RequestPasswordResetForm-submit rounded tall" color="primary" variant="contained" type="submit">
{ t('RequestPasswordResetForm.request') }
{t('RequestPasswordResetForm.request')}
</Button>
<div>
<Button color="primary" onClick={() => skipTokenRequest(form.getState().values.userName)}>
{ t('RequestPasswordResetForm.skipRequest') }
{t('RequestPasswordResetForm.skipRequest')}
</Button>
</div>
</form>

View file

@ -0,0 +1,26 @@
import { useState } from 'react';
import { useReduxEffect } from '@app/hooks';
import { ServerTypes } from '@app/store';
export interface RequestPasswordResetForm {
errorMessage: boolean;
setErrorMessage: (v: boolean) => void;
isMFA: boolean;
setIsMFA: (v: boolean) => void;
}
export function useRequestPasswordResetForm(): RequestPasswordResetForm {
const [errorMessage, setErrorMessage] = useState(false);
const [isMFA, setIsMFA] = useState(false);
useReduxEffect(() => {
setErrorMessage(true);
}, ServerTypes.RESET_PASSWORD_FAILED, []);
useReduxEffect(() => {
setIsMFA(true);
}, ServerTypes.RESET_PASSWORD_CHALLENGE, []);
return { errorMessage, setErrorMessage, isMFA, setIsMFA };
}

View file

@ -1,24 +1,18 @@
// eslint-disable-next-line
import React, { useEffect, useState } 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 Typography from '@mui/material/Typography';
import { InputField, KnownHosts } from '@app/components';
import { useReduxEffect } from '@app/hooks';
import { ServerTypes } from '@app/store';
import { useResetPasswordForm } from './useResetPasswordForm';
import './ResetPasswordForm.css';
const ResetPasswordForm = ({ onSubmit, userName }) => {
const [errorMessage, setErrorMessage] = useState(false);
const { t } = useTranslation();
useReduxEffect(() => {
setErrorMessage(true);
}, ServerTypes.RESET_PASSWORD_FAILED, []);
const { errorMessage } = useResetPasswordForm();
const validate = values => {
const errors: any = {};
@ -53,7 +47,7 @@ const ResetPasswordForm = ({ onSubmit, userName }) => {
token = token?.trim();
onSubmit({ userName, token, ...values });
}
};
return (
<Form onSubmit={handleOnSubmit} validate={validate} initialValues={{ userName }}>
@ -96,12 +90,12 @@ const ResetPasswordForm = ({ onSubmit, userName }) => {
{errorMessage && (
<div className='ResetPasswordForm-item'>
<Typography color="error">{ t('ResetPasswordForm.error') }</Typography>
<Typography color="error">{t('ResetPasswordForm.error')}</Typography>
</div>
)}
</div>
<Button className='ResetPasswordForm-submit rounded tall' color='primary' variant='contained' type='submit'>
{ t('ResetPasswordForm.label.reset') }
{t('ResetPasswordForm.label.reset')}
</Button>
</form>
)}

View file

@ -0,0 +1,18 @@
import { useState } from 'react';
import { useReduxEffect } from '@app/hooks';
import { ServerTypes } from '@app/store';
export interface ResetPasswordForm {
errorMessage: boolean;
}
export function useResetPasswordForm(): ResetPasswordForm {
const [errorMessage, setErrorMessage] = useState(false);
useReduxEffect(() => {
setErrorMessage(true);
}, ServerTypes.RESET_PASSWORD_FAILED, []);
return { errorMessage };
}