diff --git a/webclient/src/__test-utils__/makeHookWrapper.tsx b/webclient/src/__test-utils__/makeHookWrapper.tsx new file mode 100644 index 000000000..6a2514d84 --- /dev/null +++ b/webclient/src/__test-utils__/makeHookWrapper.tsx @@ -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( + reducer: Reducer, + preloadedState: S, +) { + const store = configureStore({ + reducer, + preloadedState: preloadedState as Parameters[0]['preloadedState'], + }); + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + return { Wrapper, store }; +} + +export interface MakeReduxWebClientHookWrapperOptions { + reducer: Reducer; + preloadedState: S; + webClient?: WebClient; +} + +export function makeReduxWebClientHookWrapper({ + reducer, + preloadedState, + webClient, +}: MakeReduxWebClientHookWrapperOptions) { + const store = configureStore({ + reducer, + preloadedState: preloadedState as Parameters[0]['preloadedState'], + }); + const client = webClient ?? createMockWebClient(); + function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + } + return { Wrapper, store, webClient: client }; +} diff --git a/webclient/src/components/CardDetails/CardDetails.tsx b/webclient/src/components/CardDetails/CardDetails.tsx index 2ed241d74..be7ed6518 100644 --- a/webclient/src/components/CardDetails/CardDetails.tsx +++ b/webclient/src/components/CardDetails/CardDetails.tsx @@ -1,6 +1,3 @@ -// eslint-disable-next-line -import React, { useMemo, useState } from 'react'; - import { CardDTO } from '@app/services'; import Card from '../Card/Card'; diff --git a/webclient/src/components/Game/Battlefield/Battlefield.tsx b/webclient/src/components/Game/Battlefield/Battlefield.tsx index 18d9eff37..4f2231292 100644 --- a/webclient/src/components/Game/Battlefield/Battlefield.tsx +++ b/webclient/src/components/Game/Battlefield/Battlefield.tsx @@ -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(() => { - 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 (
diff --git a/webclient/src/components/Game/Battlefield/useBattlefield.ts b/webclient/src/components/Game/Battlefield/useBattlefield.ts new file mode 100644 index 000000000..c9d63afcf --- /dev/null +++ b/webclient/src/components/Game/Battlefield/useBattlefield.ts @@ -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(() => { + 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 }; +} diff --git a/webclient/src/components/Game/CardContextMenu/CardContextMenu.tsx b/webclient/src/components/Game/CardContextMenu/CardContextMenu.tsx index b546065b5..c49db0382 100644 --- a/webclient/src/components/Game/CardContextMenu/CardContextMenu.tsx +++ b/webclient/src/components/Game/CardContextMenu/CardContextMenu.tsx @@ -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 = [ - { 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 ( - {MOVE_TARGETS.map((t) => ( + {moveTargets.map((t) => ( handleMove(t)}> {t.label} ))} - { - onRequestMoveToLibraryAt(); - onClose(); - }} - > + Move to library at position… diff --git a/webclient/src/components/Game/CardContextMenu/useCardContextMenu.ts b/webclient/src/components/Game/CardContextMenu/useCardContextMenu.ts new file mode 100644 index 000000000..f31c2c288 --- /dev/null +++ b/webclient/src/components/Game/CardContextMenu/useCardContextMenu.ts @@ -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 = [ + { 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; + 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, + }; +} diff --git a/webclient/src/components/Game/CardSlot/CardSlot.tsx b/webclient/src/components/Game/CardSlot/CardSlot.tsx index c127dba76..312da72c5 100644 --- a/webclient/src/components/Game/CardSlot/CardSlot.tsx +++ b/webclient/src/components/Game/CardSlot/CardSlot.tsx @@ -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-` 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, diff --git a/webclient/src/components/Game/CardSlot/useCardSlot.ts b/webclient/src/components/Game/CardSlot/useCardSlot.ts new file mode 100644 index 000000000..066b6d702 --- /dev/null +++ b/webclient/src/components/Game/CardSlot/useCardSlot.ts @@ -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-` 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, + }; +} diff --git a/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.tsx b/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.tsx index d5c703ed8..5b033eae5 100644 --- a/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.tsx +++ b/webclient/src/components/Game/GameArrowOverlay/GameArrowOverlay.tsx @@ -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(() => { - 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 ( void; +} + +export interface UseGameArrowOverlayArgs { + gameId: number | undefined; + boardRef: React.RefObject; +} + +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(() => { + 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 }; +} diff --git a/webclient/src/components/Game/GameLog/GameLog.tsx b/webclient/src/components/Game/GameLog/GameLog.tsx index 4771ce58e..c58a76cb3 100644 --- a/webclient/src/components/Game/GameLog/GameLog.tsx +++ b/webclient/src/components/Game/GameLog/GameLog.tsx @@ -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(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) => { - 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 (
diff --git a/webclient/src/components/Game/GameLog/useGameLog.ts b/webclient/src/components/Game/GameLog/useGameLog.ts new file mode 100644 index 000000000..a2c0252b7 --- /dev/null +++ b/webclient/src/components/Game/GameLog/useGameLog.ts @@ -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 | undefined; + displaySeconds: number; + draft: string; + setDraft: (v: string) => void; + handleMessagesScroll: () => void; + handleSubmit: (e: React.FormEvent) => void; +} + +export interface UseGameLogArgs { + gameId: number | undefined; + listRef: RefObject; +} + +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) => { + 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, + }; +} diff --git a/webclient/src/components/Game/HandContextMenu/HandContextMenu.tsx b/webclient/src/components/Game/HandContextMenu/HandContextMenu.tsx index 5636ab4b8..fefd7c5f7 100644 --- a/webclient/src/components/Game/HandContextMenu/HandContextMenu.tsx +++ b/webclient/src/components/Game/HandContextMenu/HandContextMenu.tsx @@ -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 ( 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 }; +} diff --git a/webclient/src/components/Game/HandZone/HandZone.tsx b/webclient/src/components/Game/HandZone/HandZone.tsx index 787e80309..0c78382bf 100644 --- a/webclient/src/components/Game/HandZone/HandZone.tsx +++ b/webclient/src/components/Game/HandZone/HandZone.tsx @@ -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) => { - if (!onZoneContextMenu) { - return; - } - const target = e.target as HTMLElement; - if (target.closest('[data-card-id]')) { - return; - } - onZoneContextMenu(e); - }; - return (
; + isOver: boolean; + handleZoneContextMenu: (e: React.MouseEvent) => 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) => { + if (!onZoneContextMenu) { + return; + } + const target = e.target as HTMLElement; + if (target.closest('[data-card-id]')) { + return; + } + onZoneContextMenu(e); + }; + + return { cards, setNodeRef, isOver, handleZoneContextMenu }; +} diff --git a/webclient/src/components/Game/PhaseBar/PhaseBar.tsx b/webclient/src/components/Game/PhaseBar/PhaseBar.tsx index 3d86c48fc..1b0ddcfe4 100644 --- a/webclient/src/components/Game/PhaseBar/PhaseBar.tsx +++ b/webclient/src/components/Game/PhaseBar/PhaseBar.tsx @@ -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') { diff --git a/webclient/src/components/Game/PhaseBar/usePhaseBar.ts b/webclient/src/components/Game/PhaseBar/usePhaseBar.ts new file mode 100644 index 000000000..5a25f38e4 --- /dev/null +++ b/webclient/src/components/Game/PhaseBar/usePhaseBar.ts @@ -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 }; +} diff --git a/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.tsx b/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.tsx index 80e3b2d6b..a45a0430b 100644 --- a/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.tsx +++ b/webclient/src/components/Game/PlayerInfoPanel/PlayerInfoPanel.tsx @@ -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(null); - const [editDraft, setEditDraft] = useState(''); + const { + player, + isHost, + lifeCounter, + otherCounters, + editingId, + editDraft, + setEditDraft, + beginEdit, + commitEdit, + cancelEdit, + handleIncrement, + handleDelete, + } = usePlayerInfoPanel({ gameId, playerId }); if (!player) { return
; @@ -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) => (
  • 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(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, + }; +} diff --git a/webclient/src/components/Game/TurnControls/TurnControls.tsx b/webclient/src/components/Game/TurnControls/TurnControls.tsx index a75c8174f..ad0ef9a9a 100644 --- a/webclient/src/components/Game/TurnControls/TurnControls.tsx +++ b/webclient/src/components/Game/TurnControls/TurnControls.tsx @@ -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(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 0–10). When no phase is - // active yet (activePhase < 0 during the pre-game lobby), advance to - // Untap (0). - const current = game.activePhase; - const next = current >= 0 ? (current + 1) % 11 : 0; - 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 (
    @@ -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 diff --git a/webclient/src/components/Game/TurnControls/useTurnControls.ts b/webclient/src/components/Game/TurnControls/useTurnControls.ts new file mode 100644 index 000000000..a42f31b20 --- /dev/null +++ b/webclient/src/components/Game/TurnControls/useTurnControls.ts @@ -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(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 0–10). When no phase is + // active yet (activePhase < 0 during the pre-game lobby), advance to + // Untap (0). + const current = game.activePhase; + const next = current >= 0 ? (current + 1) % 11 : 0; + 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, + }; +} diff --git a/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.tsx b/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.tsx index 14e553ebf..b973f0329 100644 --- a/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.tsx +++ b/webclient/src/components/Game/ZoneContextMenu/ZoneContextMenu.tsx @@ -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( - Draw a card, - Draw N cards…, - Shuffle, - Dump top N…, + Draw a card, + Draw N cards…, + Shuffle, + Dump top N…, , - + Reveal top card to all , - + Reveal top N to… , , @@ -114,7 +77,7 @@ function ZoneContextMenu({ , @@ -125,13 +88,13 @@ function ZoneContextMenu({ ); } else if (zoneName === App.ZoneName.GRAVE) { menuItems.push( - + Reveal graveyard to… , ); } else if (zoneName === App.ZoneName.EXILE) { menuItems.push( - + Reveal exile to… , ); diff --git a/webclient/src/components/Game/ZoneContextMenu/useZoneContextMenu.ts b/webclient/src/components/Game/ZoneContextMenu/useZoneContextMenu.ts new file mode 100644 index 000000000..43dbcceb1 --- /dev/null +++ b/webclient/src/components/Game/ZoneContextMenu/useZoneContextMenu.ts @@ -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, + }; +} diff --git a/webclient/src/components/KnownHosts/KnownHosts.tsx b/webclient/src/components/KnownHosts/KnownHosts.tsx index de2e7b57c..f7e122c10 100644 --- a/webclient/src/components/KnownHosts/KnownHosts.tsx +++ b/webclient/src/components/KnownHosts/KnownHosts.tsx @@ -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(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 ( diff --git a/webclient/src/components/KnownHosts/useKnownHostsComponent.ts b/webclient/src/components/KnownHosts/useKnownHostsComponent.ts new file mode 100644 index 000000000..9f6017a8f --- /dev/null +++ b/webclient/src/components/KnownHosts/useKnownHostsComponent.ts @@ -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; + openAddKnownHostDialog: () => void; + openEditKnownHostDialog: (host: HostDTO) => void; + closeKnownHostDialog: () => void; + handleDialogRemove: (args: { id: number }) => Promise; + handleDialogSubmit: (args: { + id?: number; + name: string; + host: string; + port: string; + }) => Promise; +} + +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(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, + }; +} diff --git a/webclient/src/components/Message/CardCallout.tsx b/webclient/src/components/Message/CardCallout.tsx index 3872ae17f..b9b34634e 100644 --- a/webclient/src/components/Message/CardCallout.tsx +++ b/webclient/src/components/Message/CardCallout.tsx @@ -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(null); - const [token, setToken] = useState(null); - const [anchorEl, setAnchorEl] = useState(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 ( @@ -81,8 +56,8 @@ const CardCallout = ({ name }) => { }} >
    - { card && () } - { token && () } + {card && ()} + {token && ()}
    ) diff --git a/webclient/src/components/Message/Message.tsx b/webclient/src/components/Message/Message.tsx index bb5617242..c818cc991 100644 --- a/webclient/src/components/Message/Message.tsx +++ b/webclient/src/components/Message/Message.tsx @@ -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 (
    - { name && (:) } - { messageChunks } + {name && (:)} + {chunks}
    ); }; @@ -44,14 +32,7 @@ const PlayerLink = ({ name, label = name }) => ( ); -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 (); @@ -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 ({urlChunk}); @@ -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); diff --git a/webclient/src/components/Message/useCardCallout.ts b/webclient/src/components/Message/useCardCallout.ts new file mode 100644 index 000000000..f6e92a220 --- /dev/null +++ b/webclient/src/components/Message/useCardCallout.ts @@ -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(null); + const [token, setToken] = useState(null); + const [anchorEl, setAnchorEl] = useState(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 }; +} diff --git a/webclient/src/components/Message/useMessage.ts b/webclient/src/components/Message/useMessage.ts new file mode 100644 index 000000000..b8e28a427 --- /dev/null +++ b/webclient/src/components/Message/useMessage.ts @@ -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(null); + const [name, setName] = useState(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); +} diff --git a/webclient/src/components/UserDisplay/UserDisplay.tsx b/webclient/src/components/UserDisplay/UserDisplay.tsx index c3d46446e..a58f89c1d 100644 --- a/webclient/src/components/UserDisplay/UserDisplay.tsx +++ b/webclient/src/components/UserDisplay/UserDisplay.tsx @@ -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 (
    @@ -87,8 +66,4 @@ const UserDisplay = ({ user }: UserDisplayProps) => { ); }; -interface UserDisplayProps { - user: Data.ServerInfo_User; -} - export default UserDisplay; diff --git a/webclient/src/components/UserDisplay/useUserDisplay.ts b/webclient/src/components/UserDisplay/useUserDisplay.ts new file mode 100644 index 000000000..92e27e665 --- /dev/null +++ b/webclient/src/components/UserDisplay/useUserDisplay.ts @@ -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, + }; +} diff --git a/webclient/src/containers/Account/Account.tsx b/webclient/src/containers/Account/Account.tsx index 3902b31d6..e8b23cc8b 100644 --- a/webclient/src/containers/Account/Account.tsx +++ b/webclient/src/containers/Account/Account.tsx @@ -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 ( @@ -59,11 +37,11 @@ const Account = () => { Buddies Online: ?/{buddyList.length}
    ( + items={buddyList.map(user => ( - )) } + ))} />
    @@ -76,11 +54,11 @@ const Account = () => { Ignored Users Online: ?/{ignoreList.length}
    ( + items={ignoreList.map(user => ( - )) } + ))} />
    @@ -89,7 +67,7 @@ const Account = () => {
    - { avatarUrl && {name} } + {avatarUrl && {name}}

    {name}

    Location: ({country?.toUpperCase()})

    User Level: {userLevel}

    @@ -105,12 +83,8 @@ const Account = () => {

    Server Name: {serverName}

    Server Version: {serverVersion}

    -
    @@ -119,7 +93,7 @@ const Account = () => {
    - ) -} + ); +}; export default Account; diff --git a/webclient/src/containers/Account/useAccount.ts b/webclient/src/containers/Account/useAccount.ts new file mode 100644 index 000000000..8a0ae6a90 --- /dev/null +++ b/webclient/src/containers/Account/useAccount.ts @@ -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, + }; +} diff --git a/webclient/src/containers/Game/Game.tsx b/webclient/src/containers/Game/Game.tsx index c907ad83e..622c4c43f 100644 --- a/webclient/src/containers/Game/Game.tsx +++ b/webclient/src/containers/Game/Game.tsx @@ -1,15 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - DndContext, - DragEndEvent, - DragOverlay, - DragStartEvent, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; +import { DndContext, DragOverlay } from '@dnd-kit/core'; import { AuthGuard, @@ -26,1022 +15,49 @@ import { RightPanel, StackStrip, ZoneContextMenu, - createCardRegistry, - makeCardKey, - useToast, } from '@app/components'; import { ConfirmDialog, CreateCounterDialog, CreateTokenDialog, DeckSelectDialog, - DEFAULT_DIE_COUNT, - DEFAULT_DIE_SIDES, GameInfoDialog, PromptDialog, RevealCardsDialog, RollDieDialog, SideboardDialog, cardsFromZone, - type SideboardPlanMove, ZoneViewDialog, } from '@app/dialogs'; -import { - useCurrentGame, - useGameAccess, - useGameLifecycle, - useWebClient, -} from '@app/hooks'; -import { App, Data } from '@app/types'; +import { App } from '@app/types'; import Layout from '../Layout/Layout'; +import { useGame } from './useGame'; + import './Game.css'; -interface ZoneViewTarget { - playerId: number; - zoneName: string; -} - -interface CardMenuState { - card: Data.ServerInfo_Card; - sourcePlayerId: number; - sourceZone: string; - anchorPosition: { top: number; left: number }; -} - -interface ZoneMenuState { - playerId: number; - zoneName: string; - anchorPosition: { top: number; left: number }; -} - -interface PromptState { - title: string; - label: string; - initialValue?: string; - helperText?: string; - validate?: (value: string) => string | null; - onSubmit: (value: string) => void; -} - -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 AnchorPosition { - top: number; - left: number; -} - -interface RevealState { - title: string; - zoneName: string; - zoneLabel: string; - showCountInput: boolean; - defaultCount: number; - onSubmit: (args: { targetPlayerId: number; topCards: number }) => void; -} - -interface ArrowDragState { - sourcePlayerId: number; - sourceZone: string; - sourceCardId: number; - startX: number; - startY: number; - currentX: number; - currentY: number; - moved: boolean; -} - -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; - function Game() { - const { gameId, game, localPlayer, isSpectator, isJudge } = useCurrentGame(); - const webClient = useWebClient(); - 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); - }, - }); - - const [hoveredCard, setHoveredCard] = useState(null); - const [selectedOpponentId, setSelectedOpponentId] = useState(); - const [zoneViews, setZoneViews] = useState([]); - const [activeCard, setActiveCard] = useState(null); - const [cardMenu, setCardMenu] = useState(null); - const [zoneMenu, setZoneMenu] = useState(null); - const [prompt, setPrompt] = useState(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(null); - const [playerMenu, setPlayerMenu] = useState(null); - const [handMenu, setHandMenu] = useState(null); - const [pendingArrow, setPendingArrow] = useState(null); - const [pendingAttach, setPendingAttach] = useState(null); - const [arrowDrag, setArrowDrag] = useState(null); - const [concedeConfirm, setConcedeConfirm] = useState<'concede' | 'unconcede' | null>(null); - const [gameInfoOpen, setGameInfoOpen] = useState(false); - // 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 boardRef = useRef(null); - const suppressNextContextMenuRef = useRef(false); - const cardRegistry = useMemo(() => createCardRegistry(), []); - const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor)); - - 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]); - - 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]); - - // 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]); - - // 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 = (e: React.MouseEvent) => { - 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 shownOpponentId = selectedOpponentId ?? opponents[0]?.playerId; - - const localAccess = useGameAccess(gameId, game?.localPlayerId); - const opponentAccess = useGameAccess(gameId, shownOpponentId); - - // 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 && - !isSpectator && - !isJudge && - !localPlayer.properties.readyStart; - - 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(() => { - 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]); - - const handleZoneClick = (playerId: number, zoneName: string) => { - setZoneViews((prev) => { - if (prev.some((v) => v.playerId === playerId && v.zoneName === zoneName)) { - return prev; - } - return [...prev, { playerId, zoneName }]; - }); - }; - - const handleCloseZoneView = (playerId: number, zoneName: string) => { - setZoneViews((prev) => - prev.filter((v) => !(v.playerId === playerId && v.zoneName === zoneName)), - ); - }; - - const handleDragStart = (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. - if (pendingArrow) { - setPendingArrow(null); - } - if (pendingAttach) { - setPendingAttach(null); - } - }; - - const handleDragEnd = (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, - }); - }; - - const handleDragCancel = () => { - setActiveCard(null); - }; - - const handleCardContextMenu = ( - 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 = ( - 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 }, - }); - }; - - const handlePlayerContextMenu = (event: React.MouseEvent) => { - if (gameId == null || isSpectator || localAccess.canAct === false) { - return; - } - event.preventDefault(); - setPlayerMenu({ top: event.clientY, left: event.clientX }); - }; - - const handleHandContextMenu = (event: React.MouseEvent) => { - if (gameId == null || isSpectator || localAccess.canAct === false) { - return; - } - event.preventDefault(); - setHandMenu({ top: event.clientY, left: event.clientX }); - }; - - const handleCardClick = ( - 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); - }; - - const handleCardDoubleClick = ( - 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', - }); - }; - - const handleRequestSetPT = () => { - 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); - }, - }); - }; - - const handleRequestSetAnnotation = () => { - 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); - }, - }); - }; - - const handleRequestSetCardCounter = () => { - 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); - }, - }); - }; - - const handleRequestDrawArrow = () => { - const menu = cardMenu; - if (!menu) { - return; - } - setPendingArrow({ - sourcePlayerId: menu.sourcePlayerId, - sourceZone: menu.sourceZone, - sourceCardId: menu.card.id, - }); - }; - - const handleRequestAttach = () => { - const menu = cardMenu; - if (!menu) { - return; - } - setPendingAttach({ - sourcePlayerId: menu.sourcePlayerId, - sourceZone: menu.sourceZone, - sourceCardId: menu.card.id, - }); - }; - - const handleRequestMoveToLibraryAt = () => { - 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); - }, - }); - }; - - const handleRequestDrawN = () => { - 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); - }, - }); - }; - - const handleRequestDumpN = () => { - 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); - }, - }); - }; - - const handleRollDieSubmit = ({ sides, count }: { sides: number; count: number }) => { - if (gameId == null) { - return; - } - webClient.request.game.rollDie(gameId, { sides, count }); - setLastDieSides(sides); - setLastDieCount(count); - setRollDieOpen(false); - }; - - const handleCreateCounterSubmit = ({ - 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); - }; - - const handleCreateTokenSubmit = (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); - }; - - const handleSideboardSubmit = (moveList: SideboardPlanMove[]) => { - if (gameId == null) { - return; - } - webClient.request.game.setSideboardPlan(gameId, { moveList }); - setSideboardOpen(false); - }; - - const handleToggleSideboardLock = (locked: boolean) => { - if (gameId == null) { - return; - } - webClient.request.game.setSideboardLock(gameId, { locked }); - }; - - const handleRequestChooseMulligan = () => { - 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); - }, - }); - }; - - const handleRequestRevealHand = () => { - 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); - }, - }); - }; - - const handleRequestRevealRandom = () => { - 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); - }, - }); - }; - - const handleRequestRevealTopN = () => { - 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); - }, - }); - }; - - const handleRequestRevealZone = () => { - 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); - }, - }); - }; - - const revealPlayers = 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]); + const g = useGame(); + const { + gameId, + game, + localPlayer, + boardRef, + cardRegistry, + sensors, + hoveredCard, + setHoveredCard, + isRotated, + toggleRotated, + localAccess, + opponentAccess, + deckSelectOpen, + opponents, + arrows, + dialogs, + dnd, + } = g; return ( @@ -1049,9 +65,9 @@ function Game() {
    @@ -1059,12 +75,12 @@ function Game() {
    {!game && ( @@ -1073,7 +89,7 @@ function Game() {
    )} - {game && shownOpponentId != null && ( + {game && opponents.shownOpponentId != null && (
    - handleCardContextMenu(shownOpponentId, App.ZoneName.TABLE, card, e) + dialogs.handleCardContextMenu(opponents.shownOpponentId!, App.ZoneName.TABLE, card, e) } onCardDoubleClick={(card) => - handleCardDoubleClick(App.ZoneName.TABLE, card) + arrows.handleCardDoubleClick(App.ZoneName.TABLE, card) } - onZoneClick={handleZoneClick} - onZoneContextMenu={handleZoneContextMenu} + onZoneClick={dialogs.handleZoneClick} + onZoneContextMenu={dialogs.handleZoneContextMenu} /> o.playerId === shownOpponentId)?.name ?? - `p${shownOpponentId}`, + opponents.opponents.find((o) => o.playerId === opponents.shownOpponentId)?.name ?? + `p${opponents.shownOpponentId}`, }, { playerId: game.localPlayerId, @@ -1114,216 +130,206 @@ function Game() { `p${game.localPlayerId}`, }, ]} - onZoneClick={handleZoneClick} + onZoneClick={dialogs.handleZoneClick} /> - handleCardContextMenu(game.localPlayerId, App.ZoneName.TABLE, card, e) + dialogs.handleCardContextMenu(game.localPlayerId, App.ZoneName.TABLE, card, e) } onCardDoubleClick={(card) => - handleCardDoubleClick(App.ZoneName.TABLE, card) + arrows.handleCardDoubleClick(App.ZoneName.TABLE, card) } - onZoneClick={handleZoneClick} - onZoneContextMenu={handleZoneContextMenu} - onRequestCreateCounter={() => setCreateCounterOpen(true)} - onPlayerContextMenu={handlePlayerContextMenu} + onZoneClick={dialogs.handleZoneClick} + onZoneContextMenu={dialogs.handleZoneContextMenu} + onRequestCreateCounter={dialogs.openCreateCounter} + onPlayerContextMenu={dialogs.handlePlayerContextMenu} /> {localPlayer && ( - handleCardContextMenu(game.localPlayerId, App.ZoneName.HAND, card, e) + dialogs.handleCardContextMenu(game.localPlayerId, App.ZoneName.HAND, card, e) } - onZoneContextMenu={handleHandContextMenu} + onZoneContextMenu={dialogs.handleHandContextMenu} /> )}
    )} - +
    setRollDieOpen(true)} - onRequestConcede={() => setConcedeConfirm('concede')} - onRequestUnconcede={() => setConcedeConfirm('unconcede')} - onRequestGameInfo={() => setGameInfoOpen(true)} - onToggleRotate90={() => setIsRotated((prev) => !prev)} + onRequestRollDie={dialogs.openRollDie} + onRequestConcede={dialogs.openConcede} + onRequestUnconcede={dialogs.openUnconcede} + onRequestGameInfo={dialogs.openGameInfo} + onToggleRotate90={toggleRotated} isRotated={isRotated} /> - {zoneViews.map((v, idx) => ( + {dialogs.zoneViews.map((v, idx) => ( handleCloseZoneView(v.playerId, v.zoneName)} + handleClose={() => dialogs.handleCloseZoneView(v.playerId, v.zoneName)} initialPosition={{ x: 80 + idx * 36, y: 80 + idx * 36 }} /> ))} setCardMenu(null)} - onRequestSetPT={handleRequestSetPT} - onRequestSetAnnotation={handleRequestSetAnnotation} - onRequestSetCounter={handleRequestSetCardCounter} - onRequestDrawArrow={handleRequestDrawArrow} - onRequestAttach={handleRequestAttach} - onRequestMoveToLibraryAt={handleRequestMoveToLibraryAt} + card={dialogs.cardMenu?.card ?? null} + ownerPlayerId={dialogs.cardMenu?.sourcePlayerId ?? null} + sourceZone={dialogs.cardMenu?.sourceZone ?? null} + onClose={dialogs.closeCardMenu} + onRequestSetPT={dialogs.handleRequestSetPT} + onRequestSetAnnotation={dialogs.handleRequestSetAnnotation} + onRequestSetCounter={dialogs.handleRequestSetCardCounter} + onRequestDrawArrow={dialogs.handleRequestDrawArrow} + onRequestAttach={dialogs.handleRequestAttach} + onRequestMoveToLibraryAt={dialogs.handleRequestMoveToLibraryAt} /> setZoneMenu(null)} - onRequestDrawN={handleRequestDrawN} - onRequestDumpN={handleRequestDumpN} - onRequestRevealTopN={handleRequestRevealTopN} - onRequestRevealZone={handleRequestRevealZone} + playerId={dialogs.zoneMenu?.playerId ?? null} + zoneName={dialogs.zoneMenu?.zoneName ?? null} + onClose={dialogs.closeZoneMenu} + onRequestDrawN={dialogs.handleRequestDrawN} + onRequestDumpN={dialogs.handleRequestDumpN} + onRequestRevealTopN={dialogs.handleRequestRevealTopN} + onRequestRevealZone={dialogs.handleRequestRevealZone} /> setPlayerMenu(null)} - onRequestCreateToken={() => setCreateTokenOpen(true)} - onRequestViewSideboard={() => setSideboardOpen(true)} + isOpen={dialogs.playerMenu != null} + anchorPosition={dialogs.playerMenu} + onClose={dialogs.closePlayerMenu} + onRequestCreateToken={dialogs.openCreateToken} + onRequestViewSideboard={dialogs.openSideboard} /> setHandMenu(null)} - onRequestChooseMulligan={handleRequestChooseMulligan} - onRequestRevealHand={handleRequestRevealHand} - onRequestRevealRandom={handleRequestRevealRandom} + onClose={dialogs.closeHandMenu} + onRequestChooseMulligan={dialogs.handleRequestChooseMulligan} + onRequestRevealHand={dialogs.handleRequestRevealHand} + onRequestRevealRandom={dialogs.handleRequestRevealRandom} /> - {prompt && ( + {dialogs.prompt && ( setPrompt(null)} + title={dialogs.prompt.title} + label={dialogs.prompt.label} + initialValue={dialogs.prompt.initialValue} + helperText={dialogs.prompt.helperText} + validate={dialogs.prompt.validate} + onSubmit={dialogs.prompt.onSubmit} + onCancel={dialogs.closePrompt} /> )} setRollDieOpen(false)} + isOpen={dialogs.rollDieOpen} + lastSides={dialogs.lastDieSides} + lastCount={dialogs.lastDieCount} + onSubmit={dialogs.handleRollDieSubmit} + onCancel={dialogs.closeRollDie} /> setCreateCounterOpen(false)} + isOpen={dialogs.createCounterOpen} + onSubmit={dialogs.handleCreateCounterSubmit} + onCancel={dialogs.closeCreateCounter} /> setCreateTokenOpen(false)} + isOpen={dialogs.createTokenOpen} + onSubmit={dialogs.handleCreateTokenSubmit} + onCancel={dialogs.closeCreateToken} /> setSideboardOpen(false)} - onToggleLock={handleToggleSideboardLock} + onSubmit={dialogs.handleSideboardSubmit} + onCancel={dialogs.closeSideboard} + onToggleLock={dialogs.handleToggleSideboardLock} /> - {revealState && ( + {dialogs.revealState && ( setRevealState(null)} + title={dialogs.revealState.title} + zoneLabel={dialogs.revealState.zoneLabel} + showCountInput={dialogs.revealState.showCountInput} + defaultCount={dialogs.revealState.defaultCount} + players={opponents.revealPlayers} + onSubmit={dialogs.revealState.onSubmit} + onCancel={dialogs.closeReveal} /> )} { - if (gameId != null) { - webClient.request.game.concede(gameId); - } - setConcedeConfirm(null); - }} - onCancel={() => setConcedeConfirm(null)} + onConfirm={dialogs.confirmConcede} + onCancel={dialogs.closeConcedeConfirm} /> { - if (gameId != null) { - webClient.request.game.unconcede(gameId); - } - setConcedeConfirm(null); - }} - onCancel={() => setConcedeConfirm(null)} + onConfirm={dialogs.confirmUnconcede} + onCancel={dialogs.closeConcedeConfirm} /> setGameInfoOpen(false)} + onClose={dialogs.closeGameInfo} />
    - {activeCard ? : null} + {dnd.activeCard ? : null} diff --git a/webclient/src/containers/Game/useGame.ts b/webclient/src/containers/Game/useGame.ts new file mode 100644 index 000000000..17a20d695 --- /dev/null +++ b/webclient/src/containers/Game/useGame.ts @@ -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; + cardRegistry: CardRegistry; + sensors: ReturnType; + 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(null); + const cardRegistry = useMemo(() => createCardRegistry(), []); + const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor)); + const [hoveredCard, setHoveredCard] = useState(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, + }; +} diff --git a/webclient/src/containers/Game/useGameArrowInteractions.spec.ts b/webclient/src/containers/Game/useGameArrowInteractions.spec.ts new file mode 100644 index 000000000..571a50309 --- /dev/null +++ b/webclient/src/containers/Game/useGameArrowInteractions.spec.ts @@ -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(); + 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 = {}) { + 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); + }); + + 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); + }); + 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); + }); + 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); + }); + 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(); + }); +}); diff --git a/webclient/src/containers/Game/useGameArrowInteractions.ts b/webclient/src/containers/Game/useGameArrowInteractions.ts new file mode 100644 index 000000000..c994bf419 --- /dev/null +++ b/webclient/src/containers/Game/useGameArrowInteractions.ts @@ -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) => 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; + 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(null); + const [pendingAttach, setPendingAttach] = useState(null); + const [arrowDrag, setArrowDrag] = useState(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) => { + 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(() => { + 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, + }; +} diff --git a/webclient/src/containers/Game/useGameDialogs.ts b/webclient/src/containers/Game/useGameDialogs.ts new file mode 100644 index 000000000..e7927d57e --- /dev/null +++ b/webclient/src/containers/Game/useGameDialogs.ts @@ -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([]); + const [cardMenu, setCardMenu] = useState(null); + const [zoneMenu, setZoneMenu] = useState(null); + const [prompt, setPrompt] = useState(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(null); + const [playerMenu, setPlayerMenu] = useState(null); + const [handMenu, setHandMenu] = useState(null); + const [concedeConfirm, setConcedeConfirm] = useState(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, + }; +} diff --git a/webclient/src/containers/Game/useGameDnd.ts b/webclient/src/containers/Game/useGameDnd.ts new file mode 100644 index 000000000..96d959e7e --- /dev/null +++ b/webclient/src/containers/Game/useGameDnd.ts @@ -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(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 }; +} diff --git a/webclient/src/containers/Game/useGameLifecycleNavigation.ts b/webclient/src/containers/Game/useGameLifecycleNavigation.ts new file mode 100644 index 000000000..91e3036f2 --- /dev/null +++ b/webclient/src/containers/Game/useGameLifecycleNavigation.ts @@ -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); + }, + }); +} diff --git a/webclient/src/containers/Game/useGameOpponentSelector.ts b/webclient/src/containers/Game/useGameOpponentSelector.ts new file mode 100644 index 000000000..e09f9f129 --- /dev/null +++ b/webclient/src/containers/Game/useGameOpponentSelector.ts @@ -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(); + + 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]); + + 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, + }; +} diff --git a/webclient/src/containers/Layout/LeftNav.tsx b/webclient/src/containers/Layout/LeftNav.tsx index 00d89455b..ffcb3a7fd 100644 --- a/webclient/src/containers/Layout/LeftNav.tsx +++ b/webclient/src/containers/Layout/LeftNav.tsx @@ -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({ - 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 (
    @@ -86,11 +35,11 @@ const LeftNav = () => { logo - { isConnected && ( + {isConnected && ( - ) } + )}
    - { isConnected && ( + {isConnected && (
    - + Games
    - + Decks @@ -173,7 +122,7 @@ const LeftNav = () => {
    - ) } + )}
  • { >
    ); -} +}; export default LeftNav; diff --git a/webclient/src/containers/Layout/useLeftNav.ts b/webclient/src/containers/Layout/useLeftNav.ts new file mode 100644 index 000000000..3b3a0c4ce --- /dev/null +++ b/webclient/src/containers/Layout/useLeftNav.ts @@ -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({ + 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, + }; +} diff --git a/webclient/src/containers/Login/Login.tsx b/webclient/src/containers/Login/Login.tsx index 78bcdf242..423a09793 100644 --- a/webclient/src/containers/Login/Login.tsx +++ b/webclient/src/containers/Login/Login.tsx @@ -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(null); - - const rememberLoginRef = useRef(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 = { - ...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 ( - { isConnected && } + {isConnected && }
    @@ -251,8 +95,8 @@ const Login = () => { logo COCKATRICE
    - { t('LoginContainer.header.title') } - { t('LoginContainer.header.subtitle') } + {t('LoginContainer.header.title')} + {t('LoginContainer.header.subtitle')}
    { />
    - { - showDescription() && ( - - {description} - - ) - } + {showDescription() && ( + + {description} + + )}
    - { t('LoginContainer.footer.registerPrompt') } - + {t('LoginContainer.footer.registerPrompt')} +
    - { t('LoginContainer.footer.credit') } - { new Date().getUTCFullYear() } + {t('LoginContainer.footer.credit')} - {new Date().getUTCFullYear()} - { - serverProps.REACT_APP_VERSION && ( - - { t('LoginContainer.footer.version') }: { serverProps.REACT_APP_VERSION } - - ) - } + {serverProps.REACT_APP_VERSION && ( + + {t('LoginContainer.footer.version')}: {serverProps.REACT_APP_VERSION} + + )}
    @@ -321,9 +161,8 @@ const Login = () => {
    - { /**/} -

    { t('LoginContainer.content.subtitle1') }

    -

    { t('LoginContainer.content.subtitle2') }

    +

    {t('LoginContainer.content.subtitle1')}

    +

    {t('LoginContainer.content.subtitle2')}

    @@ -357,6 +196,6 @@ const Login = () => { ); -} +}; export default Login; diff --git a/webclient/src/containers/Login/useLogin.ts b/webclient/src/containers/Login/useLogin.ts new file mode 100644 index 000000000..4955010d2 --- /dev/null +++ b/webclient/src/containers/Login/useLogin.ts @@ -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(null); + + const rememberLoginRef = useRef(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'), + }); + + 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 = { + ...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, + }; +} diff --git a/webclient/src/containers/Logs/LogResults.tsx b/webclient/src/containers/Logs/LogResults.tsx index 201ca0db0..02cf3511a 100644 --- a/webclient/src/containers/Logs/LogResults.tsx +++ b/webclient/src/containers/Logs/LogResults.tsx @@ -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) => { - ) + ); }; const a11yProps = index => { @@ -97,13 +81,13 @@ const Results = ({ headerCells, logs }) => ( - { headerCells.map(({ label }) => ( + {headerCells.map(({ label }) => ( {label} ))} - { logs.map(({ time, senderName, senderIp, message, targetId, targetName }, index) => ( + {logs.map(({ time, senderName, senderIp, message, targetId, targetName }, index) => ( {time} {senderName} diff --git a/webclient/src/containers/Logs/Logs.tsx b/webclient/src/containers/Logs/Logs.tsx index bf2fe9a6d..1c7dbfe40 100644 --- a/webclient/src/containers/Logs/Logs.tsx +++ b/webclient/src/containers/Logs/Logs.tsx @@ -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 (
    @@ -74,13 +26,3 @@ const Logs = () => { }; export default Logs; - - - - - - - - - - diff --git a/webclient/src/containers/Logs/useLogResults.ts b/webclient/src/containers/Logs/useLogResults.ts new file mode 100644 index 000000000..68e174bbe --- /dev/null +++ b/webclient/src/containers/Logs/useLogResults.ts @@ -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 }; +} diff --git a/webclient/src/containers/Logs/useLogs.ts b/webclient/src/containers/Logs/useLogs.ts new file mode 100644 index 000000000..7e2a1057b --- /dev/null +++ b/webclient/src/containers/Logs/useLogs.ts @@ -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 }; +} diff --git a/webclient/src/containers/Room/GameSelector/GameSelector.spec.tsx b/webclient/src/containers/Room/GameSelector/GameSelector.spec.tsx index 1d4cb41f8..097fffa13 100644 --- a/webclient/src/containers/Room/GameSelector/GameSelector.spec.tsx +++ b/webclient/src/containers/Room/GameSelector/GameSelector.spec.tsx @@ -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); }); diff --git a/webclient/src/containers/Room/Games.tsx b/webclient/src/containers/Room/Games.tsx index 578895633..43777f35c 100644 --- a/webclient/src/containers/Room/Games.tsx +++ b/webclient/src/containers/Room/Games.tsx @@ -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 (
    - { 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) => { - { 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) => { - + {gameType} ? diff --git a/webclient/src/containers/Room/OpenGames.tsx b/webclient/src/containers/Room/OpenGames.tsx index 34699be50..916d7823e 100644 --- a/webclient/src/containers/Room/OpenGames.tsx +++ b/webclient/src/containers/Room/OpenGames.tsx @@ -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 (
    - { 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) => { - { 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; diff --git a/webclient/src/containers/Room/Room.tsx b/webclient/src/containers/Room/Room.tsx index fdde6803c..92750d441 100644 --- a/webclient/src/containers/Room/Room.tsx +++ b/webclient/src/containers/Room/Room.tsx @@ -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 ( @@ -76,15 +48,15 @@ const Room = () => { side={(
    - Users in this room: {users.length} + Users in this room: {users.length}
    ( + items={users.map(user => ( - )) } + ))} />
    )} @@ -92,6 +64,6 @@ const Room = () => {
    ); -} +}; export default Room; diff --git a/webclient/src/containers/Room/useGames.ts b/webclient/src/containers/Room/useGames.ts new file mode 100644 index 000000000..b00dba1cb --- /dev/null +++ b/webclient/src/containers/Room/useGames.ts @@ -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 }; +} diff --git a/webclient/src/containers/Room/useOpenGames.ts b/webclient/src/containers/Room/useOpenGames.ts new file mode 100644 index 000000000..17f1136bc --- /dev/null +++ b/webclient/src/containers/Room/useOpenGames.ts @@ -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 }; +} diff --git a/webclient/src/containers/Room/useRoom.ts b/webclient/src/containers/Room/useRoom.ts new file mode 100644 index 000000000..466f6f8f2 --- /dev/null +++ b/webclient/src/containers/Room/useRoom.ts @@ -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 }; +} diff --git a/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.tsx b/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.tsx index 03a13e510..71b4020aa 100644 --- a/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.tsx +++ b/webclient/src/dialogs/CreateCounterDialog/CreateCounterDialog.tsx @@ -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 = [ ]; function CreateCounterDialog({ isOpen, onSubmit, onCancel }: CreateCounterDialogProps) { - const [name, setName] = useState(''); - const [selectedIdx, setSelectedIdx] = useState(0); - const [error, setError] = useState(null); - - useEffect(() => { - if (isOpen) { - setName(''); - setSelectedIdx(0); - setError(null); - } - }, [isOpen]); - - const handleSubmit = (e?: React.FormEvent) => { - 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 ( { - 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' } }} diff --git a/webclient/src/dialogs/CreateCounterDialog/useCreateCounterDialog.ts b/webclient/src/dialogs/CreateCounterDialog/useCreateCounterDialog.ts new file mode 100644 index 000000000..3c783dd56 --- /dev/null +++ b/webclient/src/dialogs/CreateCounterDialog/useCreateCounterDialog.ts @@ -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) => 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(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) => { + 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 }; +} diff --git a/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.tsx b/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.tsx index e11ef2506..a8fa78554 100644 --- a/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.tsx +++ b/webclient/src/dialogs/CreateTokenDialog/CreateTokenDialog.tsx @@ -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(null); - - useEffect(() => { - if (isOpen) { - setName(''); - setColor(DEFAULT_COLOR); - setPT(''); - setAnnotation(''); - setDestroyOnZoneChange(true); - setFaceDown(false); - setError(null); - } - }, [isOpen]); - - const handleSubmit = (e?: React.FormEvent) => { - 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 ( { - 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 } }} diff --git a/webclient/src/dialogs/CreateTokenDialog/useCreateTokenDialog.ts b/webclient/src/dialogs/CreateTokenDialog/useCreateTokenDialog.ts new file mode 100644 index 000000000..40aea51c8 --- /dev/null +++ b/webclient/src/dialogs/CreateTokenDialog/useCreateTokenDialog.ts @@ -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) => 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(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) => { + 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, + }; +} diff --git a/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.tsx b/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.tsx index 61761d68d..aa4755fcb 100644 --- a/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.tsx +++ b/webclient/src/dialogs/DeckSelectDialog/DeckSelectDialog.tsx @@ -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 ( 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, + }; +} diff --git a/webclient/src/dialogs/PromptDialog/PromptDialog.tsx b/webclient/src/dialogs/PromptDialog/PromptDialog.tsx index d20f2bc99..708cbe0d3 100644 --- a/webclient/src/dialogs/PromptDialog/PromptDialog.tsx +++ b/webclient/src/dialogs/PromptDialog/PromptDialog.tsx @@ -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(null); - - useEffect(() => { - if (isOpen) { - setValue(initialValue); - setError(null); - } - }, [isOpen, initialValue]); - - const handleSubmit = (e?: React.FormEvent) => { - 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 ( { - 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 } }} diff --git a/webclient/src/dialogs/PromptDialog/usePromptDialog.ts b/webclient/src/dialogs/PromptDialog/usePromptDialog.ts new file mode 100644 index 000000000..b00ec22ad --- /dev/null +++ b/webclient/src/dialogs/PromptDialog/usePromptDialog.ts @@ -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) => 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(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) => { + e?.preventDefault(); + if (validate) { + const message = validate(value); + if (message) { + setError(message); + return; + } + } + onSubmit(value); + }; + + return { value, error, handleChange, handleSubmit }; +} diff --git a/webclient/src/dialogs/RevealCardsDialog/RevealCardsDialog.tsx b/webclient/src/dialogs/RevealCardsDialog/RevealCardsDialog.tsx index 619f116c5..ac2092ad9 100644 --- a/webclient/src/dialogs/RevealCardsDialog/RevealCardsDialog.tsx +++ b/webclient/src/dialogs/RevealCardsDialog/RevealCardsDialog.tsx @@ -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(ALL_PLAYERS); - const [countDraft, setCountDraft] = useState(String(defaultCount)); - const [error, setError] = useState(null); - - useEffect(() => { - if (isOpen) { - setTargetPlayerId(ALL_PLAYERS); - setCountDraft(String(defaultCount)); - setError(null); - } - }, [isOpen, defaultCount]); - - const handleSubmit = (e?: React.FormEvent) => { - 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 ( { - 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'} diff --git a/webclient/src/dialogs/RevealCardsDialog/useRevealCardsDialog.ts b/webclient/src/dialogs/RevealCardsDialog/useRevealCardsDialog.ts new file mode 100644 index 000000000..0aac3bce3 --- /dev/null +++ b/webclient/src/dialogs/RevealCardsDialog/useRevealCardsDialog.ts @@ -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) => 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(ALL_PLAYERS); + const [countDraft, setCountDraft] = useState(String(defaultCount)); + const [error, setError] = useState(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) => { + 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, + }; +} diff --git a/webclient/src/dialogs/RollDieDialog/RollDieDialog.tsx b/webclient/src/dialogs/RollDieDialog/RollDieDialog.tsx index c02239acd..f6ae4210d 100644 --- a/webclient/src/dialogs/RollDieDialog/RollDieDialog.tsx +++ b/webclient/src/dialogs/RollDieDialog/RollDieDialog.tsx @@ -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) => { - 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 ( { - 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' } }} diff --git a/webclient/src/dialogs/RollDieDialog/useRollDieDialog.ts b/webclient/src/dialogs/RollDieDialog/useRollDieDialog.ts new file mode 100644 index 000000000..c092731fc --- /dev/null +++ b/webclient/src/dialogs/RollDieDialog/useRollDieDialog.ts @@ -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) => 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) => { + 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 }; +} diff --git a/webclient/src/dialogs/ZoneViewDialog/ZoneViewDialog.tsx b/webclient/src/dialogs/ZoneViewDialog/ZoneViewDialog.tsx index 351688590..5d7ee33ba 100644 --- a/webclient/src/dialogs/ZoneViewDialog/ZoneViewDialog.tsx +++ b/webclient/src/dialogs/ZoneViewDialog/ZoneViewDialog.tsx @@ -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) => { - 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) => { - 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) => { - 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; diff --git a/webclient/src/dialogs/ZoneViewDialog/useZoneViewDialog.ts b/webclient/src/dialogs/ZoneViewDialog/useZoneViewDialog.ts new file mode 100644 index 000000000..f0ec7c07e --- /dev/null +++ b/webclient/src/dialogs/ZoneViewDialog/useZoneViewDialog.ts @@ -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) => void; + handlePointerMove: (e: React.PointerEvent) => void; + handlePointerUp: (e: React.PointerEvent) => 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) => { + 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) => { + 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) => { + 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 }; +} diff --git a/webclient/src/forms/CardImportForm/CardImportForm.tsx b/webclient/src/forms/CardImportForm/CardImportForm.tsx index ab959ea99..1f609812f 100644 --- a/webclient/src/forms/CardImportForm/CardImportForm.tsx +++ b/webclient/src/forms/CardImportForm/CardImportForm.tsx @@ -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 }) => {
    - { getStepContent(activeStep) } + {getStepContent(activeStep)}
    - { loading && ( + {loading && (
    - ) } + )} ); }; diff --git a/webclient/src/forms/CardImportForm/useCardImportForm.ts b/webclient/src/forms/CardImportForm/useCardImportForm.ts new file mode 100644 index 000000000..33cbb5012 --- /dev/null +++ b/webclient/src/forms/CardImportForm/useCardImportForm.ts @@ -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; + handleTokenDownload: (args: { tokenDownloadUrl: string }) => void; +} + +export function useCardImportForm(): CardImportForm { + 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 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, + }; +} diff --git a/webclient/src/forms/LoginForm/LoginForm.tsx b/webclient/src/forms/LoginForm/LoginForm.tsx index c33a9aaf1..e4076289c 100644 --- a/webclient/src/forms/LoginForm/LoginForm.tsx +++ b/webclient/src/forms/LoginForm/LoginForm.tsx @@ -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 , - // 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 (
    @@ -106,9 +61,7 @@ const LoginFormBody = ({ setUseStoredPasswordLabel(false)} - onBlur={() => - togglePasswordLabel(canUseStoredPassword(values.remember, values.password)) - } + onBlur={passwordFieldBlur} name="password" type="password" component={InputField} diff --git a/webclient/src/forms/LoginForm/useLoginForm.ts b/webclient/src/forms/LoginForm/useLoginForm.ts new file mode 100644 index 000000000..4cc16dae6 --- /dev/null +++ b/webclient/src/forms/LoginForm/useLoginForm.ts @@ -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 , + // 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, + }; +} diff --git a/webclient/src/forms/RegisterForm/RegisterForm.tsx b/webclient/src/forms/RegisterForm/RegisterForm.tsx index 3754c857c..21e60bf24 100644 --- a/webclient/src/forms/RegisterForm/RegisterForm.tsx +++ b/webclient/src/forms/RegisterForm/RegisterForm.tsx @@ -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 ( @@ -149,12 +128,12 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => { - { error && ( + {error && (
    {error}
    diff --git a/webclient/src/forms/RegisterForm/useRegisterForm.ts b/webclient/src/forms/RegisterForm/useRegisterForm.ts new file mode 100644 index 000000000..f9a7961bc --- /dev/null +++ b/webclient/src/forms/RegisterForm/useRegisterForm.ts @@ -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(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); + + return { + emailRequired, + emailError, + passwordError, + userNameError, + error, + onHostChange, + onEmailChange, + onPasswordChange, + onUserNameChange, + }; +} diff --git a/webclient/src/forms/RequestPasswordResetForm/RequestPasswordResetForm.tsx b/webclient/src/forms/RequestPasswordResetForm/RequestPasswordResetForm.tsx index 46776cd37..83a686dca 100644 --- a/webclient/src/forms/RequestPasswordResetForm/RequestPasswordResetForm.tsx +++ b/webclient/src/forms/RequestPasswordResetForm/RequestPasswordResetForm.tsx @@ -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 (
    @@ -68,7 +57,7 @@ const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => { {isMFA ? (
    -
    { t('RequestPasswordResetForm.mfaEnabled') }
    +
    {t('RequestPasswordResetForm.mfaEnabled')}
    ) : null}
    @@ -78,18 +67,18 @@ const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => { {errorMessage && (
    - { t('RequestPasswordResetForm.error') } + {t('RequestPasswordResetForm.error')}
    )}
    diff --git a/webclient/src/forms/RequestPasswordResetForm/useRequestPasswordResetForm.ts b/webclient/src/forms/RequestPasswordResetForm/useRequestPasswordResetForm.ts new file mode 100644 index 000000000..b22f7fd05 --- /dev/null +++ b/webclient/src/forms/RequestPasswordResetForm/useRequestPasswordResetForm.ts @@ -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 }; +} diff --git a/webclient/src/forms/ResetPasswordForm/ResetPasswordForm.tsx b/webclient/src/forms/ResetPasswordForm/ResetPasswordForm.tsx index 9e9984edf..d00681176 100644 --- a/webclient/src/forms/ResetPasswordForm/ResetPasswordForm.tsx +++ b/webclient/src/forms/ResetPasswordForm/ResetPasswordForm.tsx @@ -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 (
    @@ -96,12 +90,12 @@ const ResetPasswordForm = ({ onSubmit, userName }) => { {errorMessage && (
    - { t('ResetPasswordForm.error') } + {t('ResetPasswordForm.error')}
    )} )} diff --git a/webclient/src/forms/ResetPasswordForm/useResetPasswordForm.ts b/webclient/src/forms/ResetPasswordForm/useResetPasswordForm.ts new file mode 100644 index 000000000..37043c9f7 --- /dev/null +++ b/webclient/src/forms/ResetPasswordForm/useResetPasswordForm.ts @@ -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 }; +}