mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-07-06 05:23:56 -07:00
use component hooks
This commit is contained in:
parent
515dff6d7b
commit
3aa8c654cc
81 changed files with 5203 additions and 3173 deletions
53
webclient/src/__test-utils__/makeHookWrapper.tsx
Normal file
53
webclient/src/__test-utils__/makeHookWrapper.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { configureStore, Reducer } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import { WebClientContext } from '../hooks/useWebClient';
|
||||||
|
import type { WebClient } from '../websocket';
|
||||||
|
import { createMockWebClient } from './mockWebClient';
|
||||||
|
|
||||||
|
// Minimal Provider wrapper for hook-only tests. Use this instead of
|
||||||
|
// `renderWithProviders` when you need `renderHook` — the full provider tree
|
||||||
|
// auto-instantiates the singleton store via `@app/store`, which races with
|
||||||
|
// any test-local store you preload. Deep-import the reducer(s) you need and
|
||||||
|
// pass them here (see useCurrentGame.spec.tsx for the canonical pattern).
|
||||||
|
|
||||||
|
export function makeReduxHookWrapper<S>(
|
||||||
|
reducer: Reducer<S>,
|
||||||
|
preloadedState: S,
|
||||||
|
) {
|
||||||
|
const store = configureStore({
|
||||||
|
reducer,
|
||||||
|
preloadedState: preloadedState as Parameters<typeof configureStore>[0]['preloadedState'],
|
||||||
|
});
|
||||||
|
function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <Provider store={store}>{children}</Provider>;
|
||||||
|
}
|
||||||
|
return { Wrapper, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MakeReduxWebClientHookWrapperOptions<S> {
|
||||||
|
reducer: Reducer<S>;
|
||||||
|
preloadedState: S;
|
||||||
|
webClient?: WebClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeReduxWebClientHookWrapper<S>({
|
||||||
|
reducer,
|
||||||
|
preloadedState,
|
||||||
|
webClient,
|
||||||
|
}: MakeReduxWebClientHookWrapperOptions<S>) {
|
||||||
|
const store = configureStore({
|
||||||
|
reducer,
|
||||||
|
preloadedState: preloadedState as Parameters<typeof configureStore>[0]['preloadedState'],
|
||||||
|
});
|
||||||
|
const client = webClient ?? createMockWebClient();
|
||||||
|
function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
|
<WebClientContext value={client}>{children}</WebClientContext>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { Wrapper, store, webClient: client };
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
// eslint-disable-next-line
|
|
||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { CardDTO } from '@app/services';
|
import { CardDTO } from '@app/services';
|
||||||
|
|
||||||
import Card from '../Card/Card';
|
import Card from '../Card/Card';
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { App, Data } from '@app/types';
|
import { App, Data } from '@app/types';
|
||||||
import { GameSelectors, useAppSelector } from '@app/store';
|
|
||||||
import { useSettings } from '@app/hooks';
|
|
||||||
|
|
||||||
import CardSlot from '../CardSlot/CardSlot';
|
import CardSlot from '../CardSlot/CardSlot';
|
||||||
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
|
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
|
||||||
import BattlefieldRow from './BattlefieldRow';
|
import BattlefieldRow from './BattlefieldRow';
|
||||||
|
import { useBattlefield } from './useBattlefield';
|
||||||
|
|
||||||
import './Battlefield.css';
|
import './Battlefield.css';
|
||||||
|
|
||||||
|
|
@ -21,13 +19,6 @@ export interface BattlefieldProps {
|
||||||
onCardDoubleClick?: (card: Data.ServerInfo_Card) => void;
|
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({
|
function Battlefield({
|
||||||
gameId,
|
gameId,
|
||||||
playerId,
|
playerId,
|
||||||
|
|
@ -39,28 +30,7 @@ function Battlefield({
|
||||||
onCardContextMenu,
|
onCardContextMenu,
|
||||||
onCardDoubleClick,
|
onCardDoubleClick,
|
||||||
}: BattlefieldProps) {
|
}: BattlefieldProps) {
|
||||||
const cards = useAppSelector((state) =>
|
const { rows, rowOrder, isInverted } = useBattlefield({ gameId, playerId, mirrored });
|
||||||
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.TABLE),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { value: settings } = useSettings();
|
|
||||||
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
|
|
||||||
// Mirrors desktop TableZone::isInverted() — XOR of per-player mirrored and
|
|
||||||
// the global invertVerticalCoordinate preference.
|
|
||||||
const isInverted = mirrored !== invertVerticalCoordinate;
|
|
||||||
|
|
||||||
const rows = useMemo<Data.ServerInfo_Card[][]>(() => {
|
|
||||||
const bucketed: Data.ServerInfo_Card[][] = Array.from({ length: ROW_COUNT }, () => []);
|
|
||||||
for (const card of cards) {
|
|
||||||
bucketed[rowIndexFor(card)].push(card);
|
|
||||||
}
|
|
||||||
for (const row of bucketed) {
|
|
||||||
row.sort((a, b) => (a.x ?? 0) - (b.x ?? 0));
|
|
||||||
}
|
|
||||||
return bucketed;
|
|
||||||
}, [cards]);
|
|
||||||
|
|
||||||
const rowOrder = isInverted ? [2, 1, 0] : [0, 1, 2];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="battlefield" data-testid="battlefield">
|
<div className="battlefield" data-testid="battlefield">
|
||||||
|
|
|
||||||
50
webclient/src/components/Game/Battlefield/useBattlefield.ts
Normal file
50
webclient/src/components/Game/Battlefield/useBattlefield.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { App, Data } from '@app/types';
|
||||||
|
import { GameSelectors, useAppSelector } from '@app/store';
|
||||||
|
import { useSettings } from '@app/hooks';
|
||||||
|
|
||||||
|
export interface Battlefield {
|
||||||
|
rows: Data.ServerInfo_Card[][];
|
||||||
|
rowOrder: number[];
|
||||||
|
isInverted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseBattlefieldArgs {
|
||||||
|
gameId: number;
|
||||||
|
playerId: number;
|
||||||
|
mirrored: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW_COUNT = 3;
|
||||||
|
|
||||||
|
function rowIndexFor(card: Data.ServerInfo_Card): number {
|
||||||
|
const y = card.y ?? 0;
|
||||||
|
return Math.max(0, Math.min(ROW_COUNT - 1, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBattlefield({ gameId, playerId, mirrored }: UseBattlefieldArgs): Battlefield {
|
||||||
|
const cards = useAppSelector((state) =>
|
||||||
|
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.TABLE),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { value: settings } = useSettings();
|
||||||
|
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
|
||||||
|
// Mirrors desktop TableZone::isInverted() — XOR of per-player mirrored and
|
||||||
|
// the global invertVerticalCoordinate preference.
|
||||||
|
const isInverted = mirrored !== invertVerticalCoordinate;
|
||||||
|
|
||||||
|
const rows = useMemo<Data.ServerInfo_Card[][]>(() => {
|
||||||
|
const bucketed: Data.ServerInfo_Card[][] = Array.from({ length: ROW_COUNT }, () => []);
|
||||||
|
for (const card of cards) {
|
||||||
|
bucketed[rowIndexFor(card)].push(card);
|
||||||
|
}
|
||||||
|
for (const row of bucketed) {
|
||||||
|
row.sort((a, b) => (a.x ?? 0) - (b.x ?? 0));
|
||||||
|
}
|
||||||
|
return bucketed;
|
||||||
|
}, [cards]);
|
||||||
|
|
||||||
|
const rowOrder = isInverted ? [2, 1, 0] : [0, 1, 2];
|
||||||
|
|
||||||
|
return { rows, rowOrder, isInverted };
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,9 @@ import Menu from '@mui/material/Menu';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
import Divider from '@mui/material/Divider';
|
import Divider from '@mui/material/Divider';
|
||||||
|
|
||||||
import { useWebClient } from '@app/hooks';
|
import { Data } from '@app/types';
|
||||||
import { App, Data } from '@app/types';
|
|
||||||
|
import { useCardContextMenu } from './useCardContextMenu';
|
||||||
|
|
||||||
import './CardContextMenu.css';
|
import './CardContextMenu.css';
|
||||||
|
|
||||||
|
|
@ -24,156 +25,33 @@ export interface CardContextMenuProps {
|
||||||
onRequestMoveToLibraryAt: () => void;
|
onRequestMoveToLibraryAt: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MoveTarget {
|
function CardContextMenu(props: CardContextMenuProps) {
|
||||||
label: string;
|
const { isOpen, anchorPosition, card, onClose } = props;
|
||||||
zone: string;
|
const {
|
||||||
x: number;
|
ready,
|
||||||
y: number;
|
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 —
|
if (!ready || !card) {
|
||||||
// six fixed targets plus one prompt ("Move to library at position…") for the
|
|
||||||
// 7-entry parity. Note that desktop's "Send to Table" label maps to our
|
|
||||||
// "Send to Battlefield" (same wire semantics: zone=table, x=0, y=0); the
|
|
||||||
// label diverges but the command is identical.
|
|
||||||
const MOVE_TARGETS: ReadonlyArray<MoveTarget> = [
|
|
||||||
{ label: 'Send to Hand', zone: App.ZoneName.HAND, x: -1, y: 0 },
|
|
||||||
{ label: 'Send to Battlefield', zone: App.ZoneName.TABLE, x: 0, y: 0 },
|
|
||||||
{ label: 'Send to Graveyard', zone: App.ZoneName.GRAVE, x: 0, y: 0 },
|
|
||||||
{ label: 'Send to Exile', zone: App.ZoneName.EXILE, x: 0, y: 0 },
|
|
||||||
{ label: 'Send to Library (top)', zone: App.ZoneName.DECK, x: 0, y: 0 },
|
|
||||||
{ label: 'Send to Library (bottom)', zone: App.ZoneName.DECK, x: -1, y: 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
function CardContextMenu({
|
|
||||||
isOpen,
|
|
||||||
anchorPosition,
|
|
||||||
gameId,
|
|
||||||
localPlayerId,
|
|
||||||
card,
|
|
||||||
ownerPlayerId,
|
|
||||||
sourceZone,
|
|
||||||
onClose,
|
|
||||||
onRequestSetPT,
|
|
||||||
onRequestSetAnnotation,
|
|
||||||
onRequestSetCounter,
|
|
||||||
onRequestDrawArrow,
|
|
||||||
onRequestAttach,
|
|
||||||
onRequestMoveToLibraryAt,
|
|
||||||
}: CardContextMenuProps) {
|
|
||||||
const webClient = useWebClient();
|
|
||||||
|
|
||||||
if (!card || ownerPlayerId == null || sourceZone == null || localPlayerId == null) {
|
|
||||||
return null;
|
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 (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
|
|
@ -212,17 +90,12 @@ function CardContextMenu({
|
||||||
{isOwnedByLocal && (
|
{isOwnedByLocal && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
{MOVE_TARGETS.map((t) => (
|
{moveTargets.map((t) => (
|
||||||
<MenuItem key={t.label} onClick={() => handleMove(t)}>
|
<MenuItem key={t.label} onClick={() => handleMove(t)}>
|
||||||
{t.label}
|
{t.label}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
<MenuItem
|
<MenuItem onClick={handleMoveToLibraryAt}>
|
||||||
onClick={() => {
|
|
||||||
onRequestMoveToLibraryAt();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Move to library at position…
|
Move to library at position…
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
|
import { App, Data } from '@app/types';
|
||||||
|
|
||||||
|
interface MoveTarget {
|
||||||
|
label: string;
|
||||||
|
zone: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors desktop's cockatrice/src/game/player/menu/move_menu.cpp:32-42 —
|
||||||
|
// six fixed targets plus one prompt ("Move to library at position…") for the
|
||||||
|
// 7-entry parity. Note that desktop's "Send to Table" label maps to our
|
||||||
|
// "Send to Battlefield" (same wire semantics: zone=table, x=0, y=0); the
|
||||||
|
// label diverges but the command is identical.
|
||||||
|
export const CARD_MOVE_TARGETS: ReadonlyArray<MoveTarget> = [
|
||||||
|
{ label: 'Send to Hand', zone: App.ZoneName.HAND, x: -1, y: 0 },
|
||||||
|
{ label: 'Send to Battlefield', zone: App.ZoneName.TABLE, x: 0, y: 0 },
|
||||||
|
{ label: 'Send to Graveyard', zone: App.ZoneName.GRAVE, x: 0, y: 0 },
|
||||||
|
{ label: 'Send to Exile', zone: App.ZoneName.EXILE, x: 0, y: 0 },
|
||||||
|
{ label: 'Send to Library (top)', zone: App.ZoneName.DECK, x: 0, y: 0 },
|
||||||
|
{ label: 'Send to Library (bottom)', zone: App.ZoneName.DECK, x: -1, y: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface CardContextMenu {
|
||||||
|
ready: boolean;
|
||||||
|
isOwnedByLocal: boolean;
|
||||||
|
canAttach: boolean;
|
||||||
|
isAttached: boolean;
|
||||||
|
moveTargets: ReadonlyArray<MoveTarget>;
|
||||||
|
handleFlip: () => void;
|
||||||
|
handleTapToggle: () => void;
|
||||||
|
handleFaceDownToggle: () => void;
|
||||||
|
handleDoesntUntapToggle: () => void;
|
||||||
|
handleSetPT: () => void;
|
||||||
|
handleSetAnnotation: () => void;
|
||||||
|
handleCardCounterDelta: (delta: number) => void;
|
||||||
|
handleSetCardCounter: () => void;
|
||||||
|
handleDrawArrow: () => void;
|
||||||
|
handleAttach: () => void;
|
||||||
|
handleUnattach: () => void;
|
||||||
|
handleMove: (target: MoveTarget) => void;
|
||||||
|
handleMoveToLibraryAt: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCardContextMenuArgs {
|
||||||
|
gameId: number;
|
||||||
|
localPlayerId: number | null;
|
||||||
|
card: Data.ServerInfo_Card | null;
|
||||||
|
ownerPlayerId: number | null;
|
||||||
|
sourceZone: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onRequestSetPT: () => void;
|
||||||
|
onRequestSetAnnotation: () => void;
|
||||||
|
onRequestSetCounter: () => void;
|
||||||
|
onRequestDrawArrow: () => void;
|
||||||
|
onRequestAttach: () => void;
|
||||||
|
onRequestMoveToLibraryAt: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCardContextMenu({
|
||||||
|
gameId,
|
||||||
|
localPlayerId,
|
||||||
|
card,
|
||||||
|
ownerPlayerId,
|
||||||
|
sourceZone,
|
||||||
|
onClose,
|
||||||
|
onRequestSetPT,
|
||||||
|
onRequestSetAnnotation,
|
||||||
|
onRequestSetCounter,
|
||||||
|
onRequestDrawArrow,
|
||||||
|
onRequestAttach,
|
||||||
|
onRequestMoveToLibraryAt,
|
||||||
|
}: UseCardContextMenuArgs): CardContextMenu {
|
||||||
|
const webClient = useWebClient();
|
||||||
|
|
||||||
|
const ready = card != null && ownerPlayerId != null && sourceZone != null && localPlayerId != null;
|
||||||
|
|
||||||
|
// Mutating actions (tap, flip, counters, attrs, P/T, annotation, attach,
|
||||||
|
// move) require ownership of the card — matches desktop's
|
||||||
|
// `card_menu.cpp:151-161` which drops all mutators when the menu target
|
||||||
|
// isn't getLocalOrJudge()-modifiable. Read-only actions (Draw arrow)
|
||||||
|
// stay available for planning/communication.
|
||||||
|
const isOwnedByLocal = ready && ownerPlayerId === localPlayerId;
|
||||||
|
const isAttached = ready && (card!.attachCardId ?? -1) >= 0;
|
||||||
|
// Desktop's actAttach is only available from a table card; other zones
|
||||||
|
// never expose the attach arrow.
|
||||||
|
const canAttach = ready && sourceZone === App.ZoneName.TABLE;
|
||||||
|
|
||||||
|
const setAttr = (attribute: Data.CardAttribute, value: string) => {
|
||||||
|
if (!ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.setCardAttr(gameId, {
|
||||||
|
zone: sourceZone!,
|
||||||
|
cardId: card!.id,
|
||||||
|
attribute,
|
||||||
|
attrValue: value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFlip = () => {
|
||||||
|
if (!ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// TODO(card-db): desktop's Player::actCardMenuFlip reads the card's stored
|
||||||
|
// P/T and forwards it so the revealed side shows the correct stats
|
||||||
|
// (cockatrice/src/game/player/player_actions.cpp:1805-1810). We can't
|
||||||
|
// do that without a card-database-by-name lookup, which isn't wired in
|
||||||
|
// the webclient yet. The server re-derives PT from the card DB for known
|
||||||
|
// names, so omitting `pt` is harmless for non-custom cards.
|
||||||
|
webClient.request.game.flipCard(gameId, {
|
||||||
|
zone: sourceZone!,
|
||||||
|
cardId: card!.id,
|
||||||
|
faceDown: !card!.faceDown,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTapToggle = () => {
|
||||||
|
if (!ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAttr(Data.CardAttribute.AttrTapped, card!.tapped ? '0' : '1');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFaceDownToggle = () => {
|
||||||
|
if (!ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAttr(Data.CardAttribute.AttrFaceDown, card!.faceDown ? '0' : '1');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDoesntUntapToggle = () => {
|
||||||
|
if (!ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAttr(Data.CardAttribute.AttrDoesntUntap, card!.doesntUntap ? '0' : '1');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetPT = () => {
|
||||||
|
onRequestSetPT();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetAnnotation = () => {
|
||||||
|
onRequestSetAnnotation();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardCounterDelta = (delta: number) => {
|
||||||
|
if (!ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.incCardCounter(gameId, {
|
||||||
|
zone: sourceZone!,
|
||||||
|
cardId: card!.id,
|
||||||
|
counterId: 0,
|
||||||
|
counterDelta: delta,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetCardCounter = () => {
|
||||||
|
onRequestSetCounter();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrawArrow = () => {
|
||||||
|
onRequestDrawArrow();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttach = () => {
|
||||||
|
onRequestAttach();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnattach = () => {
|
||||||
|
if (!ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Desktop's actUnattach sends only start_zone + card_id; the server uses
|
||||||
|
// proto2 presence (`has_target_player_id()`) to detect "detach". Setting
|
||||||
|
// targetPlayerId: -1 here would leave presence set and trip the attach
|
||||||
|
// code path server-side. MessageInitShape makes these fields optional,
|
||||||
|
// so omitting them produces an unset wire field.
|
||||||
|
webClient.request.game.attachCard(gameId, { startZone: sourceZone!, cardId: card!.id });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMove = (target: MoveTarget) => {
|
||||||
|
if (!ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// targetPlayerId is the ACTING player (local), matching desktop's
|
||||||
|
// Player::actMoveCardTo* which uses playerInfo->getId().
|
||||||
|
webClient.request.game.moveCard(gameId, {
|
||||||
|
startPlayerId: ownerPlayerId!,
|
||||||
|
startZone: sourceZone!,
|
||||||
|
cardsToMove: { card: [{ cardId: card!.id }] },
|
||||||
|
targetPlayerId: localPlayerId!,
|
||||||
|
targetZone: target.zone,
|
||||||
|
x: target.x,
|
||||||
|
y: target.y,
|
||||||
|
isReversed: false,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveToLibraryAt = () => {
|
||||||
|
onRequestMoveToLibraryAt();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
isOwnedByLocal,
|
||||||
|
canAttach,
|
||||||
|
isAttached,
|
||||||
|
moveTargets: CARD_MOVE_TARGETS,
|
||||||
|
handleFlip,
|
||||||
|
handleTapToggle,
|
||||||
|
handleFaceDownToggle,
|
||||||
|
handleDoesntUntapToggle,
|
||||||
|
handleSetPT,
|
||||||
|
handleSetAnnotation,
|
||||||
|
handleCardCounterDelta,
|
||||||
|
handleSetCardCounter,
|
||||||
|
handleDrawArrow,
|
||||||
|
handleAttach,
|
||||||
|
handleUnattach,
|
||||||
|
handleMove,
|
||||||
|
handleMoveToLibraryAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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 type { Data } from '@app/types';
|
||||||
import { cx } from '@app/utils';
|
import { cx } from '@app/utils';
|
||||||
|
|
||||||
import { makeCardKey, useRegisterCardRef } from '../CardRegistry/CardRegistryContext';
|
import { useCardSlot } from './useCardSlot';
|
||||||
|
|
||||||
import './CardSlot.css';
|
import './CardSlot.css';
|
||||||
|
|
||||||
|
|
@ -38,55 +33,13 @@ function CardSlot({
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
}: CardSlotProps) {
|
}: CardSlotProps) {
|
||||||
const { smallUrl } = useScryfallCard(card);
|
const { smallUrl, attributes, listeners, isDragging, isOver, rootRef } = useCardSlot({
|
||||||
|
card,
|
||||||
// React-stable id salts the dnd-kit IDs so even two disabled CardSlots
|
draggable,
|
||||||
// rendering the same card (during state transitions / hidden-zone leaks)
|
ownerPlayerId,
|
||||||
// never collide. Without the salt, pre-owner/zone render cycles shared
|
zone,
|
||||||
// `card-x-x-<id>` and dnd-kit warned.
|
|
||||||
const instanceId = useId();
|
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
|
||||||
id: `card-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
|
|
||||||
data: { card, sourcePlayerId: ownerPlayerId, sourceZone: zone },
|
|
||||||
disabled: !draggable || ownerPlayerId == null || zone == null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cards on the battlefield double as drop targets for drag-to-attach.
|
|
||||||
// Other zones don't support attach (desktop's Player::actAttach rejects
|
|
||||||
// non-table targets), so the droppable is only live for TABLE.
|
|
||||||
const droppableEnabled =
|
|
||||||
ownerPlayerId != null && zone === App.ZoneName.TABLE;
|
|
||||||
const { setNodeRef: setDropRef, isOver } = useDroppable({
|
|
||||||
id: `card-drop-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
|
|
||||||
data: {
|
|
||||||
attachTarget: true,
|
|
||||||
targetPlayerId: ownerPlayerId,
|
|
||||||
targetZone: zone,
|
|
||||||
targetCardId: card.id,
|
|
||||||
},
|
|
||||||
disabled: !droppableEnabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
const registryKey =
|
|
||||||
ownerPlayerId != null && zone != null
|
|
||||||
? makeCardKey(ownerPlayerId, zone, card.id)
|
|
||||||
: null;
|
|
||||||
const registerRef = useRegisterCardRef(registryKey);
|
|
||||||
|
|
||||||
const rootRef = useCallback(
|
|
||||||
(el: HTMLElement | null) => {
|
|
||||||
registerRef(el);
|
|
||||||
if (draggable) {
|
|
||||||
setNodeRef(el);
|
|
||||||
}
|
|
||||||
if (droppableEnabled) {
|
|
||||||
setDropRef(el);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[registerRef, setNodeRef, setDropRef, draggable, droppableEnabled],
|
|
||||||
);
|
|
||||||
|
|
||||||
const className = cx('card-slot', {
|
const className = cx('card-slot', {
|
||||||
'card-slot--tapped': card.tapped,
|
'card-slot--tapped': card.tapped,
|
||||||
'card-slot--inverted': inverted,
|
'card-slot--inverted': inverted,
|
||||||
|
|
|
||||||
89
webclient/src/components/Game/CardSlot/useCardSlot.ts
Normal file
89
webclient/src/components/Game/CardSlot/useCardSlot.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { useCallback, useId } from 'react';
|
||||||
|
import {
|
||||||
|
useDraggable,
|
||||||
|
useDroppable,
|
||||||
|
type DraggableAttributes,
|
||||||
|
type DraggableSyntheticListeners,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
|
||||||
|
import { useScryfallCard } from '@app/hooks';
|
||||||
|
import { App } from '@app/types';
|
||||||
|
import type { Data } from '@app/types';
|
||||||
|
|
||||||
|
import { makeCardKey, useRegisterCardRef } from '../CardRegistry/CardRegistryContext';
|
||||||
|
|
||||||
|
export interface CardSlot {
|
||||||
|
smallUrl: string | null | undefined;
|
||||||
|
attributes: DraggableAttributes;
|
||||||
|
listeners: DraggableSyntheticListeners;
|
||||||
|
isDragging: boolean;
|
||||||
|
isOver: boolean;
|
||||||
|
rootRef: (el: HTMLElement | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCardSlotArgs {
|
||||||
|
card: Data.ServerInfo_Card;
|
||||||
|
draggable: boolean;
|
||||||
|
ownerPlayerId: number | undefined;
|
||||||
|
zone: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCardSlot({ card, draggable, ownerPlayerId, zone }: UseCardSlotArgs): CardSlot {
|
||||||
|
const { smallUrl } = useScryfallCard(card);
|
||||||
|
|
||||||
|
// React-stable id salts the dnd-kit IDs so even two disabled CardSlots
|
||||||
|
// rendering the same card (during state transitions / hidden-zone leaks)
|
||||||
|
// never collide. Without the salt, pre-owner/zone render cycles shared
|
||||||
|
// `card-x-x-<id>` and dnd-kit warned.
|
||||||
|
const instanceId = useId();
|
||||||
|
|
||||||
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
|
id: `card-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
|
||||||
|
data: { card, sourcePlayerId: ownerPlayerId, sourceZone: zone },
|
||||||
|
disabled: !draggable || ownerPlayerId == null || zone == null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cards on the battlefield double as drop targets for drag-to-attach.
|
||||||
|
// Other zones don't support attach (desktop's Player::actAttach rejects
|
||||||
|
// non-table targets), so the droppable is only live for TABLE.
|
||||||
|
const droppableEnabled =
|
||||||
|
ownerPlayerId != null && zone === App.ZoneName.TABLE;
|
||||||
|
const { setNodeRef: setDropRef, isOver } = useDroppable({
|
||||||
|
id: `card-drop-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
|
||||||
|
data: {
|
||||||
|
attachTarget: true,
|
||||||
|
targetPlayerId: ownerPlayerId,
|
||||||
|
targetZone: zone,
|
||||||
|
targetCardId: card.id,
|
||||||
|
},
|
||||||
|
disabled: !droppableEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const registryKey =
|
||||||
|
ownerPlayerId != null && zone != null
|
||||||
|
? makeCardKey(ownerPlayerId, zone, card.id)
|
||||||
|
: null;
|
||||||
|
const registerRef = useRegisterCardRef(registryKey);
|
||||||
|
|
||||||
|
const rootRef = useCallback(
|
||||||
|
(el: HTMLElement | null) => {
|
||||||
|
registerRef(el);
|
||||||
|
if (draggable) {
|
||||||
|
setNodeRef(el);
|
||||||
|
}
|
||||||
|
if (droppableEnabled) {
|
||||||
|
setDropRef(el);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[registerRef, setNodeRef, setDropRef, draggable, droppableEnabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
smallUrl,
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
isDragging,
|
||||||
|
isOver,
|
||||||
|
rootRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
import { useGameArrowOverlay } from './useGameArrowOverlay';
|
||||||
|
|
||||||
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 './GameArrowOverlay.css';
|
import './GameArrowOverlay.css';
|
||||||
|
|
||||||
|
|
@ -15,108 +8,8 @@ export interface GameArrowOverlayProps {
|
||||||
dragPreview?: { x1: number; y1: number; x2: number; y2: number; color: string } | null;
|
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) {
|
function GameArrowOverlay({ gameId, boardRef, dragPreview = null }: GameArrowOverlayProps) {
|
||||||
const webClient = useWebClient();
|
const { arrows, width, height, handleArrowClick } = useGameArrowOverlay({ gameId, boardRef });
|
||||||
const registry = useCardRegistry();
|
|
||||||
const players = useAppSelector((state) =>
|
|
||||||
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tick is bumped whenever we need to re-query DOM rects (card registry
|
|
||||||
// mutation, board resize). Keeps the overlay declarative without an external
|
|
||||||
// layout engine.
|
|
||||||
const [tick, setTick] = useState(0);
|
|
||||||
const bump = useCallback(() => {
|
|
||||||
setTick((t) => t + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!registry) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return registry.subscribe(bump);
|
|
||||||
}, [registry, bump]);
|
|
||||||
|
|
||||||
// First-paint: the board ref is null during the initial render, so `boardRect`
|
|
||||||
// is undefined and the arrows memo bails out. Bump once after mount so the
|
|
||||||
// next render sees a populated ref.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
bump();
|
|
||||||
}, [bump]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const el = boardRef.current;
|
|
||||||
if (!el || typeof ResizeObserver === 'undefined') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const ro = new ResizeObserver(() => bump());
|
|
||||||
ro.observe(el);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
}, [boardRef, bump]);
|
|
||||||
|
|
||||||
const boardRect = boardRef.current?.getBoundingClientRect();
|
|
||||||
|
|
||||||
const arrows = useMemo<ResolvedArrow[]>(() => {
|
|
||||||
if (!players || !registry || !boardRect) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const out: ResolvedArrow[] = [];
|
|
||||||
for (const player of Object.values(players) as Enriched.PlayerEntry[]) {
|
|
||||||
for (const a of Object.values(player.arrows) as Data.ServerInfo_Arrow[]) {
|
|
||||||
const sourceEl = registry.get(
|
|
||||||
makeCardKey(a.startPlayerId, a.startZone, a.startCardId),
|
|
||||||
);
|
|
||||||
const targetEl = registry.get(
|
|
||||||
makeCardKey(a.targetPlayerId, a.targetZone, a.targetCardId),
|
|
||||||
);
|
|
||||||
if (!sourceEl || !targetEl) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const s = sourceEl.getBoundingClientRect();
|
|
||||||
const t = targetEl.getBoundingClientRect();
|
|
||||||
out.push({
|
|
||||||
arrowId: a.id,
|
|
||||||
ownerPlayerId: player.properties.playerId,
|
|
||||||
x1: s.left + s.width / 2 - boardRect.left,
|
|
||||||
y1: s.top + s.height / 2 - boardRect.top,
|
|
||||||
x2: t.left + t.width / 2 - boardRect.left,
|
|
||||||
y2: t.top + t.height / 2 - boardRect.top,
|
|
||||||
color: cssColor(a.arrowColor),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// `tick` in deps intentionally re-runs the memo on DOM-layout changes.
|
|
||||||
return out;
|
|
||||||
}, [players, registry, boardRect, tick]);
|
|
||||||
|
|
||||||
const handleArrowClick = (arrowId: number) => {
|
|
||||||
if (gameId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
webClient.request.game.deleteArrow(gameId, { arrowId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const width = boardRect?.width ?? 0;
|
|
||||||
const height = boardRect?.height ?? 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
|
import { GameSelectors, useAppSelector } from '@app/store';
|
||||||
|
import { App } from '@app/types';
|
||||||
|
import type { Data, Enriched } from '@app/types';
|
||||||
|
|
||||||
|
import { makeCardKey, useCardRegistry } from '../CardRegistry/CardRegistryContext';
|
||||||
|
|
||||||
|
export interface ResolvedArrow {
|
||||||
|
arrowId: number;
|
||||||
|
ownerPlayerId: number;
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARROW_FALLBACK_CSS = App.rgbaToCss(App.ArrowColor.RED);
|
||||||
|
|
||||||
|
function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string {
|
||||||
|
if (!c) {
|
||||||
|
return ARROW_FALLBACK_CSS;
|
||||||
|
}
|
||||||
|
return App.rgbaToCss({ r: c.r, g: c.g, b: c.b, a: c.a ?? 255 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameArrowOverlay {
|
||||||
|
arrows: ResolvedArrow[];
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
handleArrowClick: (arrowId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseGameArrowOverlayArgs {
|
||||||
|
gameId: number | undefined;
|
||||||
|
boardRef: React.RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGameArrowOverlay({
|
||||||
|
gameId,
|
||||||
|
boardRef,
|
||||||
|
}: UseGameArrowOverlayArgs): GameArrowOverlay {
|
||||||
|
const webClient = useWebClient();
|
||||||
|
const registry = useCardRegistry();
|
||||||
|
const players = useAppSelector((state) =>
|
||||||
|
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tick is bumped whenever we need to re-query DOM rects (card registry
|
||||||
|
// mutation, board resize). Keeps the overlay declarative without an external
|
||||||
|
// layout engine.
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
const bump = useCallback(() => {
|
||||||
|
setTick((t) => t + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!registry) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return registry.subscribe(bump);
|
||||||
|
}, [registry, bump]);
|
||||||
|
|
||||||
|
// First-paint: the board ref is null during the initial render, so `boardRect`
|
||||||
|
// is undefined and the arrows memo bails out. Bump once after mount so the
|
||||||
|
// next render sees a populated ref.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
bump();
|
||||||
|
}, [bump]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = boardRef.current;
|
||||||
|
if (!el || typeof ResizeObserver === 'undefined') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const ro = new ResizeObserver(() => bump());
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [boardRef, bump]);
|
||||||
|
|
||||||
|
const boardRect = boardRef.current?.getBoundingClientRect();
|
||||||
|
|
||||||
|
const arrows = useMemo<ResolvedArrow[]>(() => {
|
||||||
|
if (!players || !registry || !boardRect) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out: ResolvedArrow[] = [];
|
||||||
|
for (const player of Object.values(players) as Enriched.PlayerEntry[]) {
|
||||||
|
for (const a of Object.values(player.arrows) as Data.ServerInfo_Arrow[]) {
|
||||||
|
const sourceEl = registry.get(
|
||||||
|
makeCardKey(a.startPlayerId, a.startZone, a.startCardId),
|
||||||
|
);
|
||||||
|
const targetEl = registry.get(
|
||||||
|
makeCardKey(a.targetPlayerId, a.targetZone, a.targetCardId),
|
||||||
|
);
|
||||||
|
if (!sourceEl || !targetEl) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const s = sourceEl.getBoundingClientRect();
|
||||||
|
const t = targetEl.getBoundingClientRect();
|
||||||
|
out.push({
|
||||||
|
arrowId: a.id,
|
||||||
|
ownerPlayerId: player.properties.playerId,
|
||||||
|
x1: s.left + s.width / 2 - boardRect.left,
|
||||||
|
y1: s.top + s.height / 2 - boardRect.top,
|
||||||
|
x2: t.left + t.width / 2 - boardRect.left,
|
||||||
|
y2: t.top + t.height / 2 - boardRect.top,
|
||||||
|
color: cssColor(a.arrowColor),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `tick` in deps intentionally re-runs the memo on DOM-layout changes.
|
||||||
|
return out;
|
||||||
|
}, [players, registry, boardRect, tick]);
|
||||||
|
|
||||||
|
const handleArrowClick = (arrowId: number) => {
|
||||||
|
if (gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.deleteArrow(gameId, { arrowId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const width = boardRect?.width ?? 0;
|
||||||
|
const height = boardRect?.height ?? 0;
|
||||||
|
|
||||||
|
return { arrows, width, height, handleArrowClick };
|
||||||
|
}
|
||||||
|
|
@ -1,92 +1,24 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useWebClient } from '@app/hooks';
|
|
||||||
import { GameSelectors, useAppSelector } from '@app/store';
|
import { formatElapsed, useGameLog } from './useGameLog';
|
||||||
import type { Enriched } from '@app/types';
|
|
||||||
|
|
||||||
import './GameLog.css';
|
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 {
|
export interface GameLogProps {
|
||||||
gameId: number | undefined;
|
gameId: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function GameLog({ gameId }: GameLogProps) {
|
function GameLog({ gameId }: GameLogProps) {
|
||||||
const webClient = useWebClient();
|
|
||||||
const messages = useAppSelector((state) =>
|
|
||||||
gameId != null ? GameSelectors.getMessages(state, gameId) : EMPTY_MESSAGES,
|
|
||||||
);
|
|
||||||
const players = useAppSelector((state) =>
|
|
||||||
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
|
|
||||||
);
|
|
||||||
const secondsElapsed = useAppSelector((state) =>
|
|
||||||
gameId != null ? GameSelectors.getSecondsElapsed(state, gameId) : 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Local 1Hz ticker, resynced from Redux whenever a server event delivers a
|
|
||||||
// fresh `secondsElapsed`. Mirrors desktop's QTimer(1000) +
|
|
||||||
// setGameTime(event.seconds_elapsed()) pattern in game_state.cpp.
|
|
||||||
const [displaySeconds, setDisplaySeconds] = useState(secondsElapsed);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDisplaySeconds(secondsElapsed);
|
|
||||||
}, [secondsElapsed]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (gameId == null) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const id = window.setInterval(() => {
|
|
||||||
setDisplaySeconds((prev) => prev + 1);
|
|
||||||
}, 1000);
|
|
||||||
return () => window.clearInterval(id);
|
|
||||||
}, [gameId]);
|
|
||||||
|
|
||||||
const [draft, setDraft] = useState('');
|
|
||||||
|
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
// Desktop pins the log to the bottom unless the user has scrolled up to read backlog.
|
const {
|
||||||
// Capture pin state before the new line renders so auto-scroll only fires when the
|
messages,
|
||||||
// user was already following the tail.
|
players,
|
||||||
const wasPinnedRef = useRef(true);
|
displaySeconds,
|
||||||
useEffect(() => {
|
draft,
|
||||||
const el = listRef.current;
|
setDraft,
|
||||||
if (!el) {
|
handleMessagesScroll,
|
||||||
return;
|
handleSubmit,
|
||||||
}
|
} = useGameLog({ gameId, listRef });
|
||||||
if (wasPinnedRef.current) {
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [messages.length]);
|
|
||||||
|
|
||||||
const handleMessagesScroll = () => {
|
|
||||||
const el = listRef.current;
|
|
||||||
if (!el) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
wasPinnedRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 2;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (gameId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const trimmed = draft.trim();
|
|
||||||
if (trimmed.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
webClient.request.game.gameSay(gameId, { message: trimmed });
|
|
||||||
setDraft('');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="game-log" data-testid="game-log">
|
<div className="game-log" data-testid="game-log">
|
||||||
|
|
|
||||||
111
webclient/src/components/Game/GameLog/useGameLog.ts
Normal file
111
webclient/src/components/Game/GameLog/useGameLog.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useEffect, useRef, useState, RefObject } from 'react';
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
|
import { GameSelectors, useAppSelector } from '@app/store';
|
||||||
|
import type { Enriched } from '@app/types';
|
||||||
|
|
||||||
|
const EMPTY_MESSAGES: Enriched.GameMessage[] = [];
|
||||||
|
|
||||||
|
export function formatElapsed(totalSeconds: number): string {
|
||||||
|
const s = Math.max(0, Math.floor(totalSeconds));
|
||||||
|
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
|
||||||
|
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
|
||||||
|
const ss = String(s % 60).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameLog {
|
||||||
|
messages: Enriched.GameMessage[];
|
||||||
|
players: Record<number, Enriched.PlayerEntry> | undefined;
|
||||||
|
displaySeconds: number;
|
||||||
|
draft: string;
|
||||||
|
setDraft: (v: string) => void;
|
||||||
|
handleMessagesScroll: () => void;
|
||||||
|
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseGameLogArgs {
|
||||||
|
gameId: number | undefined;
|
||||||
|
listRef: RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGameLog({ gameId, listRef }: UseGameLogArgs): GameLog {
|
||||||
|
const webClient = useWebClient();
|
||||||
|
// getMessages falls back to a shared EMPTY_ARRAY typed as ServerInfo_Card[]
|
||||||
|
// (see game.selectors.ts). The runtime array is empty, so the cast is safe;
|
||||||
|
// fixing the selector's fallback type is out of scope for this refactor.
|
||||||
|
const messages = useAppSelector((state) =>
|
||||||
|
gameId != null ? GameSelectors.getMessages(state, gameId) : EMPTY_MESSAGES,
|
||||||
|
) as Enriched.GameMessage[];
|
||||||
|
const players = useAppSelector((state) =>
|
||||||
|
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
|
||||||
|
);
|
||||||
|
const secondsElapsed = useAppSelector((state) =>
|
||||||
|
gameId != null ? GameSelectors.getSecondsElapsed(state, gameId) : 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Local 1Hz ticker, resynced from Redux whenever a server event delivers a
|
||||||
|
// fresh `secondsElapsed`. Mirrors desktop's QTimer(1000) +
|
||||||
|
// setGameTime(event.seconds_elapsed()) pattern in game_state.cpp.
|
||||||
|
const [displaySeconds, setDisplaySeconds] = useState(secondsElapsed);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplaySeconds(secondsElapsed);
|
||||||
|
}, [secondsElapsed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameId == null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
setDisplaySeconds((prev) => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [gameId]);
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
|
||||||
|
// Desktop pins the log to the bottom unless the user has scrolled up to read backlog.
|
||||||
|
// Capture pin state before the new line renders so auto-scroll only fires when the
|
||||||
|
// user was already following the tail.
|
||||||
|
const wasPinnedRef = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wasPinnedRef.current) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages.length, listRef]);
|
||||||
|
|
||||||
|
const handleMessagesScroll = () => {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wasPinnedRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.gameSay(gameId, { message: trimmed });
|
||||||
|
setDraft('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
players,
|
||||||
|
displaySeconds,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
handleMessagesScroll,
|
||||||
|
handleSubmit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import Menu from '@mui/material/Menu';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
import Divider from '@mui/material/Divider';
|
import Divider from '@mui/material/Divider';
|
||||||
|
|
||||||
import { useWebClient } from '@app/hooks';
|
import { useHandContextMenu } from './useHandContextMenu';
|
||||||
|
|
||||||
import './HandContextMenu.css';
|
import './HandContextMenu.css';
|
||||||
|
|
||||||
|
|
@ -27,51 +27,15 @@ function HandContextMenu({
|
||||||
onRequestRevealHand,
|
onRequestRevealHand,
|
||||||
onRequestRevealRandom,
|
onRequestRevealRandom,
|
||||||
}: HandContextMenuProps) {
|
}: HandContextMenuProps) {
|
||||||
const webClient = useWebClient();
|
const { handleChoose, handleSameSize, handleMinusOne, handleRevealHand, handleRevealRandom } =
|
||||||
|
useHandContextMenu({
|
||||||
const handleChoose = () => {
|
gameId,
|
||||||
if (gameId <= 0) {
|
handSize,
|
||||||
return;
|
onClose,
|
||||||
}
|
onRequestChooseMulligan,
|
||||||
onRequestChooseMulligan();
|
onRequestRevealHand,
|
||||||
onClose();
|
onRequestRevealRandom,
|
||||||
};
|
});
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
|
|
||||||
|
export interface HandContextMenu {
|
||||||
|
handleChoose: () => void;
|
||||||
|
handleSameSize: () => void;
|
||||||
|
handleMinusOne: () => void;
|
||||||
|
handleRevealHand: () => void;
|
||||||
|
handleRevealRandom: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseHandContextMenuArgs {
|
||||||
|
gameId: number;
|
||||||
|
handSize: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onRequestChooseMulligan: () => void;
|
||||||
|
onRequestRevealHand: () => void;
|
||||||
|
onRequestRevealRandom: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHandContextMenu({
|
||||||
|
gameId,
|
||||||
|
handSize,
|
||||||
|
onClose,
|
||||||
|
onRequestChooseMulligan,
|
||||||
|
onRequestRevealHand,
|
||||||
|
onRequestRevealRandom,
|
||||||
|
}: UseHandContextMenuArgs): HandContextMenu {
|
||||||
|
const webClient = useWebClient();
|
||||||
|
|
||||||
|
const handleChoose = () => {
|
||||||
|
if (gameId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onRequestChooseMulligan();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSameSize = () => {
|
||||||
|
if (gameId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.mulligan(gameId, { number: handSize });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinusOne = () => {
|
||||||
|
if (gameId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Desktop's actMulliganMinusOne floors at 1 (see
|
||||||
|
// cockatrice/src/game/player/player_actions.cpp actMulliganMinusOne);
|
||||||
|
// the server-side doMulligan rejects number < 1.
|
||||||
|
const next = Math.max(1, handSize - 1);
|
||||||
|
webClient.request.game.mulligan(gameId, { number: next });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevealHand = () => {
|
||||||
|
if (gameId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onRequestRevealHand();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevealRandom = () => {
|
||||||
|
if (gameId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onRequestRevealRandom();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { handleChoose, handleSameSize, handleMinusOne, handleRevealHand, handleRevealRandom };
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
|
||||||
import { App, Data } from '@app/types';
|
import { App, Data } from '@app/types';
|
||||||
import { GameSelectors, useAppSelector } from '@app/store';
|
|
||||||
import { cx } from '@app/utils';
|
import { cx } from '@app/utils';
|
||||||
|
|
||||||
import CardSlot from '../CardSlot/CardSlot';
|
import CardSlot from '../CardSlot/CardSlot';
|
||||||
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
|
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
|
||||||
|
import { useHandZone } from './useHandZone';
|
||||||
|
|
||||||
import './HandZone.css';
|
import './HandZone.css';
|
||||||
|
|
||||||
|
|
@ -29,33 +28,13 @@ function HandZone({
|
||||||
onCardContextMenu,
|
onCardContextMenu,
|
||||||
onZoneContextMenu,
|
onZoneContextMenu,
|
||||||
}: HandZoneProps) {
|
}: HandZoneProps) {
|
||||||
const cards = useAppSelector((state) =>
|
const { cards, setNodeRef, isOver, handleZoneContextMenu } = useHandZone({
|
||||||
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.HAND),
|
gameId,
|
||||||
);
|
playerId,
|
||||||
|
canAct,
|
||||||
// Match desktop: can't drop into a hand zone that isn't yours (judges
|
onZoneContextMenu,
|
||||||
// aside; server enforces the same restriction). Today only the local
|
|
||||||
// HandZone mounts, but this guard future-proofs opponent-hand mirrors.
|
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
|
||||||
id: `hand-${playerId}`,
|
|
||||||
data: { targetPlayerId: playerId, targetZone: App.ZoneName.HAND },
|
|
||||||
disabled: !canAct,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Right-click anywhere inside the hand that doesn't land on a card opens
|
|
||||||
// the hand zone context menu (mulligan / reveal hand). Card-level right-
|
|
||||||
// click has its own handler on CardSlot.
|
|
||||||
const handleZoneContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (!onZoneContextMenu) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('[data-card-id]')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onZoneContextMenu(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
|
|
|
||||||
55
webclient/src/components/Game/HandZone/useHandZone.ts
Normal file
55
webclient/src/components/Game/HandZone/useHandZone.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
|
import type { Ref } from 'react';
|
||||||
|
|
||||||
|
import { App, Data } from '@app/types';
|
||||||
|
import { GameSelectors, useAppSelector } from '@app/store';
|
||||||
|
|
||||||
|
export interface HandZone {
|
||||||
|
cards: Data.ServerInfo_Card[];
|
||||||
|
setNodeRef: Ref<HTMLDivElement>;
|
||||||
|
isOver: boolean;
|
||||||
|
handleZoneContextMenu: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseHandZoneArgs {
|
||||||
|
gameId: number;
|
||||||
|
playerId: number;
|
||||||
|
canAct: boolean;
|
||||||
|
onZoneContextMenu?: (event: React.MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHandZone({
|
||||||
|
gameId,
|
||||||
|
playerId,
|
||||||
|
canAct,
|
||||||
|
onZoneContextMenu,
|
||||||
|
}: UseHandZoneArgs): HandZone {
|
||||||
|
const cards = useAppSelector((state) =>
|
||||||
|
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.HAND),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Match desktop: can't drop into a hand zone that isn't yours (judges
|
||||||
|
// aside; server enforces the same restriction). Today only the local
|
||||||
|
// HandZone mounts, but this guard future-proofs opponent-hand mirrors.
|
||||||
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
|
id: `hand-${playerId}`,
|
||||||
|
data: { targetPlayerId: playerId, targetZone: App.ZoneName.HAND },
|
||||||
|
disabled: !canAct,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Right-click anywhere inside the hand that doesn't land on a card opens
|
||||||
|
// the hand zone context menu (mulligan / reveal hand). Card-level right-
|
||||||
|
// click has its own handler on CardSlot.
|
||||||
|
const handleZoneContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!onZoneContextMenu) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('[data-card-id]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onZoneContextMenu(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { cards, setNodeRef, isOver, handleZoneContextMenu };
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
|
||||||
import { useCurrentGame, useWebClient } from '@app/hooks';
|
import { App } from '@app/types';
|
||||||
import { GameSelectors, useAppSelector } from '@app/store';
|
|
||||||
import { App, Data } from '@app/types';
|
|
||||||
import { cx } from '@app/utils';
|
import { cx } from '@app/utils';
|
||||||
|
|
||||||
|
import { usePhaseBar } from './usePhaseBar';
|
||||||
|
|
||||||
import './PhaseBar.css';
|
import './PhaseBar.css';
|
||||||
|
|
||||||
export interface PhaseBarProps {
|
export interface PhaseBarProps {
|
||||||
|
|
@ -35,65 +35,8 @@ const PHASE_LABELS: ReadonlyArray<{
|
||||||
];
|
];
|
||||||
|
|
||||||
function PhaseBar({ gameId }: PhaseBarProps) {
|
function PhaseBar({ gameId }: PhaseBarProps) {
|
||||||
const webClient = useWebClient();
|
const { activePhase, canAdvance, handlePhaseClick, handlePass, handleUntapAll, handleDrawOne } =
|
||||||
const { game, isJudge, isStarted } = useCurrentGame(gameId);
|
usePhaseBar(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 onDoubleClickFor = (kind: 'untapAll' | 'drawCard' | undefined) => {
|
const onDoubleClickFor = (kind: 'untapAll' | 'drawCard' | undefined) => {
|
||||||
if (kind === 'untapAll') {
|
if (kind === 'untapAll') {
|
||||||
|
|
|
||||||
76
webclient/src/components/Game/PhaseBar/usePhaseBar.ts
Normal file
76
webclient/src/components/Game/PhaseBar/usePhaseBar.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { useCurrentGame, useWebClient } from '@app/hooks';
|
||||||
|
import { GameSelectors, useAppSelector } from '@app/store';
|
||||||
|
import { App, Data } from '@app/types';
|
||||||
|
|
||||||
|
export interface PhaseBar {
|
||||||
|
activePhase: App.Phase | undefined;
|
||||||
|
canAdvance: boolean;
|
||||||
|
handlePhaseClick: (phase: App.Phase) => void;
|
||||||
|
handlePass: () => void;
|
||||||
|
handleUntapAll: () => void;
|
||||||
|
handleDrawOne: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePhaseBar(gameId: number | undefined): PhaseBar {
|
||||||
|
const webClient = useWebClient();
|
||||||
|
const { game, isJudge, isStarted } = useCurrentGame(gameId);
|
||||||
|
const activePhase = useAppSelector((state) =>
|
||||||
|
gameId != null ? GameSelectors.getActivePhase(state, gameId) : undefined,
|
||||||
|
);
|
||||||
|
const localPlayerId = game?.localPlayerId;
|
||||||
|
const tableCards = useAppSelector((state) =>
|
||||||
|
gameId != null && localPlayerId != null
|
||||||
|
? GameSelectors.getCards(state, gameId, localPlayerId, App.ZoneName.TABLE)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Desktop: only the active player (or a judge) can advance the phase.
|
||||||
|
const canAdvance =
|
||||||
|
gameId != null &&
|
||||||
|
game != null &&
|
||||||
|
isStarted &&
|
||||||
|
(isJudge || game.activePlayerId === game.localPlayerId);
|
||||||
|
|
||||||
|
const handlePhaseClick = (phase: App.Phase) => {
|
||||||
|
if (!canAdvance || gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.setActivePhase(gameId, { phase });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePass = () => {
|
||||||
|
if (!canAdvance || gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.nextTurn(gameId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Desktop's untap-step double-click fires "Untap All" on the local player's
|
||||||
|
// table zone (cockatrice/src/game/player/player_actions.cpp actUntapAll).
|
||||||
|
// We replicate by sending one setCardAttr per tapped card; there is no
|
||||||
|
// batch variant on the wire.
|
||||||
|
const handleUntapAll = () => {
|
||||||
|
if (!canAdvance || gameId == null || !tableCards) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const card of tableCards) {
|
||||||
|
if (card.tapped) {
|
||||||
|
webClient.request.game.setCardAttr(gameId, {
|
||||||
|
zone: App.ZoneName.TABLE,
|
||||||
|
cardId: card.id,
|
||||||
|
attribute: Data.CardAttribute.AttrTapped,
|
||||||
|
attrValue: '0',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrawOne = () => {
|
||||||
|
if (!canAdvance || gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.drawCards(gameId, { number: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
return { activePhase, canAdvance, handlePhaseClick, handlePass, handleUntapAll, handleDrawOne };
|
||||||
|
}
|
||||||
|
|
@ -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 { cx } from '@app/utils';
|
||||||
import type { Data } from '@app/types';
|
import type { Data } from '@app/types';
|
||||||
|
|
||||||
|
import { cssColor, usePlayerInfoPanel } from './usePlayerInfoPanel';
|
||||||
|
|
||||||
import './PlayerInfoPanel.css';
|
import './PlayerInfoPanel.css';
|
||||||
|
|
||||||
export interface PlayerInfoPanelProps {
|
export interface PlayerInfoPanelProps {
|
||||||
|
|
@ -14,21 +13,6 @@ export interface PlayerInfoPanelProps {
|
||||||
onContextMenu?: (event: React.MouseEvent) => void;
|
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({
|
function PlayerInfoPanel({
|
||||||
gameId,
|
gameId,
|
||||||
playerId,
|
playerId,
|
||||||
|
|
@ -36,13 +20,20 @@ function PlayerInfoPanel({
|
||||||
onRequestCreateCounter,
|
onRequestCreateCounter,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
}: PlayerInfoPanelProps) {
|
}: PlayerInfoPanelProps) {
|
||||||
const webClient = useWebClient();
|
const {
|
||||||
const player = useAppSelector((state) => GameSelectors.getPlayer(state, gameId, playerId));
|
player,
|
||||||
const counters = useAppSelector((state) => GameSelectors.getCounters(state, gameId, playerId));
|
isHost,
|
||||||
const hostId = useAppSelector((state) => GameSelectors.getHostId(state, gameId));
|
lifeCounter,
|
||||||
|
otherCounters,
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
editingId,
|
||||||
const [editDraft, setEditDraft] = useState('');
|
editDraft,
|
||||||
|
setEditDraft,
|
||||||
|
beginEdit,
|
||||||
|
commitEdit,
|
||||||
|
cancelEdit,
|
||||||
|
handleIncrement,
|
||||||
|
handleDelete,
|
||||||
|
} = usePlayerInfoPanel({ gameId, playerId });
|
||||||
|
|
||||||
if (!player) {
|
if (!player) {
|
||||||
return <div className="player-info-panel player-info-panel--empty" />;
|
return <div className="player-info-panel player-info-panel--empty" />;
|
||||||
|
|
@ -53,44 +44,6 @@ function PlayerInfoPanel({
|
||||||
const conceded = player.properties.conceded;
|
const conceded = player.properties.conceded;
|
||||||
const ready = player.properties.readyStart;
|
const ready = player.properties.readyStart;
|
||||||
const sideboardLocked = player.properties.sideboardLocked ?? false;
|
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) => (
|
const renderCounterRow = (c: Data.ServerInfo_Counter) => (
|
||||||
<li
|
<li
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
|
import { GameSelectors, useAppSelector } from '@app/store';
|
||||||
|
import type { Data, Enriched } from '@app/types';
|
||||||
|
|
||||||
|
export function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string {
|
||||||
|
if (!c) {
|
||||||
|
return '#666';
|
||||||
|
}
|
||||||
|
return `rgba(${c.r}, ${c.g}, ${c.b}, ${(c.a ?? 255) / 255})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop renders Life larger/bolder than other counters (see
|
||||||
|
// cockatrice/src/game/player/player.cpp PlayerTarget sizing). We special-
|
||||||
|
// case the counter whose name is exactly 'Life' (case-insensitive) and
|
||||||
|
// pull it out of the regular counter list into a prominent life block.
|
||||||
|
export function isLifeCounter(c: { name: string }): boolean {
|
||||||
|
return c.name.trim().toLowerCase() === 'life';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerInfoPanel {
|
||||||
|
player: Enriched.PlayerEntry | undefined;
|
||||||
|
isHost: boolean;
|
||||||
|
lifeCounter: Data.ServerInfo_Counter | undefined;
|
||||||
|
otherCounters: Data.ServerInfo_Counter[];
|
||||||
|
editingId: number | null;
|
||||||
|
editDraft: string;
|
||||||
|
setEditDraft: (v: string) => void;
|
||||||
|
beginEdit: (counterId: number, currentValue: number) => void;
|
||||||
|
commitEdit: (counterId: number) => void;
|
||||||
|
cancelEdit: () => void;
|
||||||
|
handleIncrement: (counterId: number, delta: number) => void;
|
||||||
|
handleDelete: (counterId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePlayerInfoPanelArgs {
|
||||||
|
gameId: number;
|
||||||
|
playerId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayerInfoPanel({
|
||||||
|
gameId,
|
||||||
|
playerId,
|
||||||
|
}: UsePlayerInfoPanelArgs): PlayerInfoPanel {
|
||||||
|
const webClient = useWebClient();
|
||||||
|
const player = useAppSelector((state) => GameSelectors.getPlayer(state, gameId, playerId));
|
||||||
|
const counters = useAppSelector((state) => GameSelectors.getCounters(state, gameId, playerId));
|
||||||
|
const hostId = useAppSelector((state) => GameSelectors.getHostId(state, gameId));
|
||||||
|
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editDraft, setEditDraft] = useState('');
|
||||||
|
|
||||||
|
const isHost = hostId != null && hostId === playerId;
|
||||||
|
const allCounters = Object.values(counters);
|
||||||
|
const lifeCounter = allCounters.find(isLifeCounter);
|
||||||
|
const otherCounters = allCounters.filter((c) => !isLifeCounter(c));
|
||||||
|
|
||||||
|
const handleIncrement = (counterId: number, delta: number) => {
|
||||||
|
webClient.request.game.incCounter(gameId, { counterId, delta });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (counterId: number) => {
|
||||||
|
webClient.request.game.delCounter(gameId, { counterId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginEdit = (counterId: number, currentValue: number) => {
|
||||||
|
setEditingId(counterId);
|
||||||
|
setEditDraft(String(currentValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitEdit = (counterId: number) => {
|
||||||
|
const trimmed = editDraft.trim();
|
||||||
|
// Empty input cancels the edit (desktop inline edits treat blur-with-
|
||||||
|
// no-change and blur-with-empty-string identically). Prior behavior
|
||||||
|
// coerced '' → 0 because `Number('')` is 0 and `Number.isInteger(0)` is
|
||||||
|
// true, which surprised users expecting cancel-on-blank.
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
setEditingId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = Number(trimmed);
|
||||||
|
if (Number.isInteger(value)) {
|
||||||
|
webClient.request.game.setCounter(gameId, { counterId, value });
|
||||||
|
}
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
player,
|
||||||
|
isHost,
|
||||||
|
lifeCounter,
|
||||||
|
otherCounters,
|
||||||
|
editingId,
|
||||||
|
editDraft,
|
||||||
|
setEditDraft,
|
||||||
|
beginEdit,
|
||||||
|
commitEdit,
|
||||||
|
cancelEdit,
|
||||||
|
handleIncrement,
|
||||||
|
handleDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
import Menu from '@mui/material/Menu';
|
import Menu from '@mui/material/Menu';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
|
||||||
import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks';
|
import { useTurnControls } from './useTurnControls';
|
||||||
import { GameSelectors, useAppSelector } from '@app/store';
|
|
||||||
|
|
||||||
import './TurnControls.css';
|
import './TurnControls.css';
|
||||||
|
|
||||||
|
|
@ -26,129 +24,31 @@ function TurnControls({
|
||||||
onToggleRotate90,
|
onToggleRotate90,
|
||||||
isRotated,
|
isRotated,
|
||||||
}: TurnControlsProps) {
|
}: TurnControlsProps) {
|
||||||
const webClient = useWebClient();
|
const {
|
||||||
const { game, localPlayer, isSpectator, isJudge, isHost, isStarted } = useCurrentGame(gameId);
|
isHost,
|
||||||
const { status: settingsStatus, value: settings, update: updateSettings } = useSettings();
|
isConceded,
|
||||||
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
|
invertVerticalCoordinate,
|
||||||
|
settingsReady,
|
||||||
// Post-kick: the reducer has deleted the game from state but the dialog
|
canAdvance,
|
||||||
// may still be mounted for a frame while `useGameLifecycle` navigates to
|
canLeave,
|
||||||
// /server. Every handler double-checks `game` so a trailing click can't
|
canConcede,
|
||||||
// fire a command against a game the server no longer has.
|
canUnconcede,
|
||||||
const hasLiveGame = gameId != null && game != null;
|
canRoll,
|
||||||
|
canKick,
|
||||||
const [kickAnchor, setKickAnchor] = useState<HTMLElement | null>(null);
|
canRemoveArrows,
|
||||||
|
hasLiveGame,
|
||||||
const opponents = useMemo(() => {
|
opponents,
|
||||||
if (!game) {
|
kickAnchor,
|
||||||
return [];
|
setKickAnchor,
|
||||||
}
|
handlePassTurn,
|
||||||
return Object.values(game.players)
|
handleReverseTurn,
|
||||||
.filter((p) => p.properties.playerId !== game.localPlayerId)
|
handleNextPhase,
|
||||||
.map((p) => ({
|
handleConcedeToggle,
|
||||||
playerId: p.properties.playerId,
|
handleRemoveArrows,
|
||||||
name: p.properties.userInfo?.name ?? `p${p.properties.playerId}`,
|
handleLeave,
|
||||||
}));
|
handleToggleInvert,
|
||||||
}, [game]);
|
handleKick,
|
||||||
|
} = useTurnControls({ gameId, onRequestConcede, onRequestUnconcede });
|
||||||
// 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 (
|
return (
|
||||||
<div className="turn-controls" data-testid="turn-controls">
|
<div className="turn-controls" data-testid="turn-controls">
|
||||||
|
|
@ -216,7 +116,7 @@ function TurnControls({
|
||||||
className={`turn-controls__btn${invertVerticalCoordinate ? ' turn-controls__btn--active' : ''}`}
|
className={`turn-controls__btn${invertVerticalCoordinate ? ' turn-controls__btn--active' : ''}`}
|
||||||
onClick={handleToggleInvert}
|
onClick={handleToggleInvert}
|
||||||
aria-pressed={invertVerticalCoordinate}
|
aria-pressed={invertVerticalCoordinate}
|
||||||
disabled={settingsStatus !== LoadingState.READY}
|
disabled={!settingsReady}
|
||||||
title="Flip battlefield row order (saved across sessions)"
|
title="Flip battlefield row order (saved across sessions)"
|
||||||
>
|
>
|
||||||
Invert Rows
|
Invert Rows
|
||||||
|
|
|
||||||
197
webclient/src/components/Game/TurnControls/useTurnControls.ts
Normal file
197
webclient/src/components/Game/TurnControls/useTurnControls.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks';
|
||||||
|
import { GameSelectors, useAppSelector } from '@app/store';
|
||||||
|
|
||||||
|
export interface TurnControlsOpponent {
|
||||||
|
playerId: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnControls {
|
||||||
|
isHost: boolean;
|
||||||
|
isConceded: boolean;
|
||||||
|
invertVerticalCoordinate: boolean;
|
||||||
|
settingsReady: boolean;
|
||||||
|
canAdvance: boolean;
|
||||||
|
canLeave: boolean;
|
||||||
|
canConcede: boolean;
|
||||||
|
canUnconcede: boolean;
|
||||||
|
canRoll: boolean;
|
||||||
|
canKick: boolean;
|
||||||
|
canRemoveArrows: boolean;
|
||||||
|
hasLiveGame: boolean;
|
||||||
|
opponents: TurnControlsOpponent[];
|
||||||
|
kickAnchor: HTMLElement | null;
|
||||||
|
setKickAnchor: (el: HTMLElement | null) => void;
|
||||||
|
handlePassTurn: () => void;
|
||||||
|
handleReverseTurn: () => void;
|
||||||
|
handleNextPhase: () => void;
|
||||||
|
handleConcedeToggle: () => void;
|
||||||
|
handleRemoveArrows: () => void;
|
||||||
|
handleLeave: () => void;
|
||||||
|
handleToggleInvert: () => void;
|
||||||
|
handleKick: (playerId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTurnControlsArgs {
|
||||||
|
gameId: number | undefined;
|
||||||
|
onRequestConcede: () => void;
|
||||||
|
onRequestUnconcede: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTurnControls({
|
||||||
|
gameId,
|
||||||
|
onRequestConcede,
|
||||||
|
onRequestUnconcede,
|
||||||
|
}: UseTurnControlsArgs): TurnControls {
|
||||||
|
const webClient = useWebClient();
|
||||||
|
const { game, localPlayer, isSpectator, isJudge, isHost, isStarted } = useCurrentGame(gameId);
|
||||||
|
const { status: settingsStatus, value: settings, update: updateSettings } = useSettings();
|
||||||
|
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
|
||||||
|
|
||||||
|
// Post-kick: the reducer has deleted the game from state but the dialog
|
||||||
|
// may still be mounted for a frame while `useGameLifecycle` navigates to
|
||||||
|
// /server. Every handler double-checks `game` so a trailing click can't
|
||||||
|
// fire a command against a game the server no longer has.
|
||||||
|
const hasLiveGame = gameId != null && game != null;
|
||||||
|
|
||||||
|
const [kickAnchor, setKickAnchor] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const opponents = useMemo<TurnControlsOpponent[]>(() => {
|
||||||
|
if (!game) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.values(game.players)
|
||||||
|
.filter((p) => p.properties.playerId !== game.localPlayerId)
|
||||||
|
.map((p) => ({
|
||||||
|
playerId: p.properties.playerId,
|
||||||
|
name: p.properties.userInfo?.name ?? `p${p.properties.playerId}`,
|
||||||
|
}));
|
||||||
|
}, [game]);
|
||||||
|
|
||||||
|
// Local arrows belong to `localPlayerId`; Remove Local Arrows iterates
|
||||||
|
// and deletes each one. Matches desktop's Player::actRemoveLocalArrows.
|
||||||
|
const localArrows = useAppSelector((state) =>
|
||||||
|
gameId != null && game != null
|
||||||
|
? GameSelectors.getArrows(state, gameId, game.localPlayerId)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const localArrowIds = useMemo(
|
||||||
|
() => (localArrows ? Object.keys(localArrows).map(Number) : []),
|
||||||
|
[localArrows],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Players (judge or not) act as participants; pure spectators don't.
|
||||||
|
// Matches desktop: aConcede/aNextTurn are disabled when isSpectator() without
|
||||||
|
// judge privileges (see tab_game.cpp concede enablement + player_menu.cpp
|
||||||
|
// getLocalOrJudge gates).
|
||||||
|
const isParticipant = gameId != null && game != null && !isSpectator;
|
||||||
|
const isConceded = localPlayer?.properties.conceded ?? false;
|
||||||
|
const canAdvance =
|
||||||
|
gameId != null && game != null && isStarted &&
|
||||||
|
(isJudge || game.activePlayerId === game.localPlayerId);
|
||||||
|
const canLeave = gameId != null && game != null;
|
||||||
|
const canConcede = isParticipant && !isConceded;
|
||||||
|
const canUnconcede = isParticipant && isConceded;
|
||||||
|
// Rolling dice is a player action; judges may also roll. Pure spectators
|
||||||
|
// cannot (desktop exposes it through the player menu, which spectators
|
||||||
|
// don't receive).
|
||||||
|
const canRoll = gameId != null && (isParticipant || isJudge);
|
||||||
|
const canKick = gameId != null && isHost && opponents.length > 0;
|
||||||
|
const canRemoveArrows = hasLiveGame && localArrowIds.length > 0;
|
||||||
|
|
||||||
|
const handlePassTurn = () => {
|
||||||
|
if (!canAdvance || !hasLiveGame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.nextTurn(gameId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReverseTurn = () => {
|
||||||
|
if (!canAdvance || !hasLiveGame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.reverseTurn(gameId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextPhase = () => {
|
||||||
|
if (!canAdvance || !hasLiveGame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Desktop wraps at 11 → 0 (the Phase enum is 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,10 @@ import MenuItem from '@mui/material/MenuItem';
|
||||||
import Divider from '@mui/material/Divider';
|
import Divider from '@mui/material/Divider';
|
||||||
import Check from '@mui/icons-material/Check';
|
import Check from '@mui/icons-material/Check';
|
||||||
|
|
||||||
import { useWebClient } from '@app/hooks';
|
|
||||||
import { GameSelectors, useAppSelector } from '@app/store';
|
|
||||||
import { App } from '@app/types';
|
import { App } from '@app/types';
|
||||||
|
|
||||||
|
import { useZoneContextMenu } from './useZoneContextMenu';
|
||||||
|
|
||||||
import './ZoneContextMenu.css';
|
import './ZoneContextMenu.css';
|
||||||
|
|
||||||
export interface ZoneContextMenuProps {
|
export interface ZoneContextMenuProps {
|
||||||
|
|
@ -22,89 +22,52 @@ export interface ZoneContextMenuProps {
|
||||||
onRequestRevealZone: () => void;
|
onRequestRevealZone: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ZoneContextMenu({
|
function ZoneContextMenu(props: ZoneContextMenuProps) {
|
||||||
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
anchorPosition,
|
anchorPosition,
|
||||||
gameId,
|
|
||||||
playerId,
|
|
||||||
zoneName,
|
zoneName,
|
||||||
onClose,
|
onClose,
|
||||||
onRequestDrawN,
|
onRequestDrawN,
|
||||||
onRequestDumpN,
|
onRequestDumpN,
|
||||||
onRequestRevealTopN,
|
onRequestRevealTopN,
|
||||||
onRequestRevealZone,
|
onRequestRevealZone,
|
||||||
}: ZoneContextMenuProps) {
|
} = props;
|
||||||
const webClient = useWebClient();
|
const {
|
||||||
|
ready,
|
||||||
|
alwaysReveal,
|
||||||
|
alwaysLook,
|
||||||
|
handleDrawOne,
|
||||||
|
handleShuffle,
|
||||||
|
handleRevealTop,
|
||||||
|
handleToggleAlwaysReveal,
|
||||||
|
handleToggleAlwaysLook,
|
||||||
|
runAndClose,
|
||||||
|
} = useZoneContextMenu(props);
|
||||||
|
|
||||||
const zone = useAppSelector((state) =>
|
if (!ready) {
|
||||||
playerId != null && zoneName != null
|
|
||||||
? GameSelectors.getZone(state, gameId, playerId, zoneName)
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (playerId == null || zoneName == null) {
|
|
||||||
return null;
|
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[] = [];
|
const menuItems: React.ReactNode[] = [];
|
||||||
|
|
||||||
if (zoneName === App.ZoneName.DECK) {
|
if (zoneName === App.ZoneName.DECK) {
|
||||||
menuItems.push(
|
menuItems.push(
|
||||||
<MenuItem key="draw-one" onClick={run(handleDrawOne)}>Draw a card</MenuItem>,
|
<MenuItem key="draw-one" onClick={runAndClose(handleDrawOne)}>Draw a card</MenuItem>,
|
||||||
<MenuItem key="draw-n" onClick={run(onRequestDrawN)}>Draw N cards…</MenuItem>,
|
<MenuItem key="draw-n" onClick={runAndClose(onRequestDrawN)}>Draw N cards…</MenuItem>,
|
||||||
<MenuItem key="shuffle" onClick={run(handleShuffle)}>Shuffle</MenuItem>,
|
<MenuItem key="shuffle" onClick={runAndClose(handleShuffle)}>Shuffle</MenuItem>,
|
||||||
<MenuItem key="dump-n" onClick={run(onRequestDumpN)}>Dump top N…</MenuItem>,
|
<MenuItem key="dump-n" onClick={runAndClose(onRequestDumpN)}>Dump top N…</MenuItem>,
|
||||||
<Divider key="d1" />,
|
<Divider key="d1" />,
|
||||||
<MenuItem key="reveal-top" onClick={run(handleRevealTop)}>
|
<MenuItem key="reveal-top" onClick={runAndClose(handleRevealTop)}>
|
||||||
Reveal top card to all
|
Reveal top card to all
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
<MenuItem key="reveal-top-n" onClick={run(onRequestRevealTopN)}>
|
<MenuItem key="reveal-top-n" onClick={runAndClose(onRequestRevealTopN)}>
|
||||||
Reveal top N to…
|
Reveal top N to…
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
<Divider key="d2" />,
|
<Divider key="d2" />,
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key="always-reveal"
|
key="always-reveal"
|
||||||
onClick={run(handleToggleAlwaysReveal)}
|
onClick={runAndClose(handleToggleAlwaysReveal)}
|
||||||
className="zone-context-menu__toggle"
|
className="zone-context-menu__toggle"
|
||||||
>
|
>
|
||||||
<span className="zone-context-menu__check" aria-hidden>
|
<span className="zone-context-menu__check" aria-hidden>
|
||||||
|
|
@ -114,7 +77,7 @@ function ZoneContextMenu({
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key="always-look"
|
key="always-look"
|
||||||
onClick={run(handleToggleAlwaysLook)}
|
onClick={runAndClose(handleToggleAlwaysLook)}
|
||||||
className="zone-context-menu__toggle"
|
className="zone-context-menu__toggle"
|
||||||
>
|
>
|
||||||
<span className="zone-context-menu__check" aria-hidden>
|
<span className="zone-context-menu__check" aria-hidden>
|
||||||
|
|
@ -125,13 +88,13 @@ function ZoneContextMenu({
|
||||||
);
|
);
|
||||||
} else if (zoneName === App.ZoneName.GRAVE) {
|
} else if (zoneName === App.ZoneName.GRAVE) {
|
||||||
menuItems.push(
|
menuItems.push(
|
||||||
<MenuItem key="reveal-grave" onClick={run(onRequestRevealZone)}>
|
<MenuItem key="reveal-grave" onClick={runAndClose(onRequestRevealZone)}>
|
||||||
Reveal graveyard to…
|
Reveal graveyard to…
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
);
|
);
|
||||||
} else if (zoneName === App.ZoneName.EXILE) {
|
} else if (zoneName === App.ZoneName.EXILE) {
|
||||||
menuItems.push(
|
menuItems.push(
|
||||||
<MenuItem key="reveal-exile" onClick={run(onRequestRevealZone)}>
|
<MenuItem key="reveal-exile" onClick={runAndClose(onRequestRevealZone)}>
|
||||||
Reveal exile to…
|
Reveal exile to…
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Select, MenuItem } from '@mui/material';
|
import { Select, MenuItem } from '@mui/material';
|
||||||
|
|
@ -13,20 +12,13 @@ import AddIcon from '@mui/icons-material/Add';
|
||||||
import EditRoundedIcon from '@mui/icons-material/Edit';
|
import EditRoundedIcon from '@mui/icons-material/Edit';
|
||||||
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
|
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
|
||||||
|
|
||||||
import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
|
||||||
import { KnownHostDialog } from '@app/dialogs';
|
import { KnownHostDialog } from '@app/dialogs';
|
||||||
import { getHostPort, HostDTO } from '@app/services';
|
import { getHostPort, HostDTO } from '@app/services';
|
||||||
import { ServerTypes } from '@app/store';
|
|
||||||
import { App } from '@app/types';
|
|
||||||
import Toast from '../Toast/Toast';
|
import Toast from '../Toast/Toast';
|
||||||
|
|
||||||
import './KnownHosts.css';
|
import { TestConnection, useKnownHostsComponent } from './useKnownHostsComponent';
|
||||||
|
|
||||||
enum TestConnection {
|
import './KnownHosts.css';
|
||||||
TESTING = 'testing',
|
|
||||||
FAILED = 'failed',
|
|
||||||
SUCCESS = 'success',
|
|
||||||
}
|
|
||||||
|
|
||||||
const PREFIX = 'KnownHosts';
|
const PREFIX = 'KnownHosts';
|
||||||
|
|
||||||
|
|
@ -57,115 +49,31 @@ const Root = styled('div')(({ theme }) => ({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const KnownHosts = (props: any) => {
|
const KnownHosts = (props: any) => {
|
||||||
const { input, meta, disabled } = props;
|
const { input, meta, disabled } = props;
|
||||||
const onChange: (value: HostDTO) => void = input.onChange;
|
const onChange: (value: HostDTO) => void = input.onChange;
|
||||||
const { touched, error, warning } = meta;
|
const { touched, error, warning } = meta;
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const webClient = useWebClient();
|
const {
|
||||||
const knownHosts = useKnownHosts();
|
hosts,
|
||||||
|
selectedHost,
|
||||||
const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({
|
testingConnection,
|
||||||
open: false,
|
dialogState,
|
||||||
edit: null,
|
showCreateToast,
|
||||||
});
|
showDeleteToast,
|
||||||
|
showEditToast,
|
||||||
const [testingConnection, setTestingConnection] = useState<TestConnection | null>(null);
|
setShowCreateToast,
|
||||||
|
setShowDeleteToast,
|
||||||
const [showCreateToast, setShowCreateToast] = useState(false);
|
setShowEditToast,
|
||||||
const [showDeleteToast, setShowDeleteToast] = useState(false);
|
onPick,
|
||||||
const [showEditToast, setShowEditToast] = useState(false);
|
openAddKnownHostDialog,
|
||||||
|
openEditKnownHostDialog,
|
||||||
const selectedHost =
|
closeKnownHostDialog,
|
||||||
knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined;
|
handleDialogRemove,
|
||||||
const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : [];
|
handleDialogSubmit,
|
||||||
|
} = useKnownHostsComponent({ onChange });
|
||||||
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 (
|
return (
|
||||||
<Root className={'KnownHosts ' + classes.root}>
|
<Root className={'KnownHosts ' + classes.root}>
|
||||||
|
|
|
||||||
167
webclient/src/components/KnownHosts/useKnownHostsComponent.ts
Normal file
167
webclient/src/components/KnownHosts/useKnownHostsComponent.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
||||||
|
import { getHostPort, HostDTO } from '@app/services';
|
||||||
|
import { ServerTypes } from '@app/store';
|
||||||
|
import { App } from '@app/types';
|
||||||
|
|
||||||
|
export enum TestConnection {
|
||||||
|
TESTING = 'testing',
|
||||||
|
FAILED = 'failed',
|
||||||
|
SUCCESS = 'success',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnownHostsComponent {
|
||||||
|
hosts: App.Host[];
|
||||||
|
selectedHost: App.Host | undefined;
|
||||||
|
testingConnection: TestConnection | null;
|
||||||
|
dialogState: { open: boolean; edit: HostDTO | null };
|
||||||
|
showCreateToast: boolean;
|
||||||
|
showDeleteToast: boolean;
|
||||||
|
showEditToast: boolean;
|
||||||
|
setShowCreateToast: (v: boolean) => void;
|
||||||
|
setShowDeleteToast: (v: boolean) => void;
|
||||||
|
setShowEditToast: (v: boolean) => void;
|
||||||
|
onPick: (host: HostDTO) => Promise<void>;
|
||||||
|
openAddKnownHostDialog: () => void;
|
||||||
|
openEditKnownHostDialog: (host: HostDTO) => void;
|
||||||
|
closeKnownHostDialog: () => void;
|
||||||
|
handleDialogRemove: (args: { id: number }) => Promise<void>;
|
||||||
|
handleDialogSubmit: (args: {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseKnownHostsComponentArgs {
|
||||||
|
onChange: (value: HostDTO) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKnownHostsComponent({
|
||||||
|
onChange,
|
||||||
|
}: UseKnownHostsComponentArgs): KnownHostsComponent {
|
||||||
|
const webClient = useWebClient();
|
||||||
|
const knownHosts = useKnownHosts();
|
||||||
|
|
||||||
|
const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({
|
||||||
|
open: false,
|
||||||
|
edit: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [testingConnection, setTestingConnection] = useState<TestConnection | null>(null);
|
||||||
|
|
||||||
|
const [showCreateToast, setShowCreateToast] = useState(false);
|
||||||
|
const [showDeleteToast, setShowDeleteToast] = useState(false);
|
||||||
|
const [showEditToast, setShowEditToast] = useState(false);
|
||||||
|
|
||||||
|
const selectedHost =
|
||||||
|
knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined;
|
||||||
|
const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : [];
|
||||||
|
|
||||||
|
const testConnection = (host: HostDTO) => {
|
||||||
|
setTestingConnection(TestConnection.TESTING);
|
||||||
|
webClient.request.authentication.testConnection({ ...getHostPort(host) });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedHost) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(selectedHost);
|
||||||
|
testConnection(selectedHost);
|
||||||
|
}, [selectedHost]);
|
||||||
|
|
||||||
|
useReduxEffect(
|
||||||
|
() => {
|
||||||
|
setTestingConnection(TestConnection.SUCCESS);
|
||||||
|
},
|
||||||
|
ServerTypes.TEST_CONNECTION_SUCCESSFUL,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useReduxEffect(
|
||||||
|
() => {
|
||||||
|
setTestingConnection(TestConnection.FAILED);
|
||||||
|
},
|
||||||
|
ServerTypes.TEST_CONNECTION_FAILED,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPick = async (host: HostDTO) => {
|
||||||
|
if (knownHosts.status !== LoadingState.READY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(host);
|
||||||
|
await knownHosts.select(host.id!);
|
||||||
|
testConnection(host);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddKnownHostDialog = () => {
|
||||||
|
setDialogState((s) => ({ ...s, open: true, edit: null }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditKnownHostDialog = (host: HostDTO) => {
|
||||||
|
setDialogState((s) => ({ ...s, open: true, edit: host }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeKnownHostDialog = () => {
|
||||||
|
setDialogState((s) => ({ ...s, open: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogRemove = async ({ id }: { id: number }) => {
|
||||||
|
if (knownHosts.status !== LoadingState.READY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await knownHosts.remove(id);
|
||||||
|
closeKnownHostDialog();
|
||||||
|
setShowDeleteToast(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogSubmit = async ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
}: {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
}) => {
|
||||||
|
if (knownHosts.status !== LoadingState.READY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
await knownHosts.update(id, { name, host, port });
|
||||||
|
setShowEditToast(true);
|
||||||
|
} else {
|
||||||
|
const newHost: App.Host = { name, host, port, editable: true };
|
||||||
|
await knownHosts.add(newHost);
|
||||||
|
setShowCreateToast(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeKnownHostDialog();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
hosts,
|
||||||
|
selectedHost,
|
||||||
|
testingConnection,
|
||||||
|
dialogState,
|
||||||
|
showCreateToast,
|
||||||
|
showDeleteToast,
|
||||||
|
showEditToast,
|
||||||
|
setShowCreateToast,
|
||||||
|
setShowDeleteToast,
|
||||||
|
setShowEditToast,
|
||||||
|
onPick,
|
||||||
|
openAddKnownHostDialog,
|
||||||
|
openEditKnownHostDialog,
|
||||||
|
closeKnownHostDialog,
|
||||||
|
handleDialogRemove,
|
||||||
|
handleDialogSubmit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import Popover from '@mui/material/Popover';
|
import Popover from '@mui/material/Popover';
|
||||||
|
|
||||||
import { CardDTO, TokenDTO } from '@app/services';
|
|
||||||
|
|
||||||
import CardDetails from '../CardDetails/CardDetails';
|
import CardDetails from '../CardDetails/CardDetails';
|
||||||
import TokenDetails from '../TokenDetails/TokenDetails';
|
import TokenDetails from '../TokenDetails/TokenDetails';
|
||||||
|
|
||||||
|
import { useCardCallout } from './useCardCallout';
|
||||||
|
|
||||||
import './CardCallout.css';
|
import './CardCallout.css';
|
||||||
|
|
||||||
const PREFIX = 'CardCallout';
|
const PREFIX = 'CardCallout';
|
||||||
|
|
@ -28,31 +26,8 @@ const Root = styled('span')(() => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const CardCallout = ({ name }) => {
|
const CardCallout = ({ name }) => {
|
||||||
const [card, setCard] = useState<CardDTO>(null);
|
const { card, token, anchorEl, open, handlePopoverOpen, handlePopoverClose } =
|
||||||
const [token, setToken] = useState<TokenDTO>(null);
|
useCardCallout(name);
|
||||||
const [anchorEl, setAnchorEl] = useState<Element>(null);
|
|
||||||
|
|
||||||
useMemo(async () => {
|
|
||||||
const card = await CardDTO.get(name);
|
|
||||||
if (card) {
|
|
||||||
return setCard(card)
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await TokenDTO.get(name);
|
|
||||||
if (token) {
|
|
||||||
return setToken(token);
|
|
||||||
}
|
|
||||||
}, [name]);
|
|
||||||
|
|
||||||
const handlePopoverOpen = (event) => {
|
|
||||||
setAnchorEl(event.currentTarget);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePopoverClose = () => {
|
|
||||||
setAnchorEl(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const open = Boolean(anchorEl);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Root className='callout'>
|
<Root className='callout'>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { NavLink, generatePath } from 'react-router-dom';
|
import { NavLink, generatePath } from 'react-router-dom';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { App } from '@app/types';
|
import { App } from '@app/types';
|
||||||
|
|
||||||
import CardCallout from './CardCallout';
|
import CardCallout from './CardCallout';
|
||||||
|
import { useParsedMessage } from './useMessage';
|
||||||
import './Message.css';
|
import './Message.css';
|
||||||
|
|
||||||
const Message = ({ message: { message } }) => (
|
const Message = ({ message: { message } }) => (
|
||||||
|
|
@ -17,23 +16,12 @@ const Message = ({ message: { message } }) => (
|
||||||
);
|
);
|
||||||
|
|
||||||
const ParsedMessage = ({ message }) => {
|
const ParsedMessage = ({ message }) => {
|
||||||
const [messageChunks, setMessageChunks] = useState(null);
|
const { name, chunks } = useParsedMessage(message, parseChunks);
|
||||||
const [name, setName] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const name = message.match(App.MESSAGE_SENDER_REGEX);
|
|
||||||
|
|
||||||
if (name) {
|
|
||||||
setName(name[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessageChunks(parseMessage(message));
|
|
||||||
}, [message]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{name && (<strong><PlayerLink name={name} />:</strong>)}
|
{name && (<strong><PlayerLink name={name} />:</strong>)}
|
||||||
{ messageChunks }
|
{chunks}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -44,14 +32,7 @@ const PlayerLink = ({ name, label = name }) => (
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
|
|
||||||
function parseMessage(message) {
|
function parseChunks(chunk: string, index: number): ReactNode {
|
||||||
return message.replace(App.MESSAGE_SENDER_REGEX, '')
|
|
||||||
.split(App.CARD_CALLOUT_REGEX)
|
|
||||||
.filter(chunk => !!chunk)
|
|
||||||
.map(parseChunks);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseChunks(chunk, index) {
|
|
||||||
if (chunk.match(App.CARD_CALLOUT_REGEX)) {
|
if (chunk.match(App.CARD_CALLOUT_REGEX)) {
|
||||||
const name = chunk.replace(App.CALLOUT_BOUNDARY_REGEX, '').trim();
|
const name = chunk.replace(App.CALLOUT_BOUNDARY_REGEX, '').trim();
|
||||||
return (<CardCallout name={name} key={index}></CardCallout>);
|
return (<CardCallout name={name} key={index}></CardCallout>);
|
||||||
|
|
@ -68,9 +49,9 @@ function parseChunks(chunk, index) {
|
||||||
return chunk;
|
return chunk;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseUrlChunk(chunk) {
|
function parseUrlChunk(chunk: string): ReactNode {
|
||||||
return chunk.split(App.URL_REGEX)
|
return chunk.split(App.URL_REGEX)
|
||||||
.filter(urlChunk => !!urlChunk)
|
.filter((urlChunk) => !!urlChunk)
|
||||||
.map((urlChunk, index) => {
|
.map((urlChunk, index) => {
|
||||||
if (urlChunk.match(App.URL_REGEX)) {
|
if (urlChunk.match(App.URL_REGEX)) {
|
||||||
return (<a className='link' href={urlChunk} key={index} target='_blank' rel='noopener noreferrer'>{urlChunk}</a>);
|
return (<a className='link' href={urlChunk} key={index} target='_blank' rel='noopener noreferrer'>{urlChunk}</a>);
|
||||||
|
|
@ -80,9 +61,9 @@ function parseUrlChunk(chunk) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMentionChunk(chunk) {
|
function parseMentionChunk(chunk: string): ReactNode {
|
||||||
return chunk.split(App.MENTION_REGEX)
|
return chunk.split(App.MENTION_REGEX)
|
||||||
.filter(mentionChunk => !!mentionChunk)
|
.filter((mentionChunk) => !!mentionChunk)
|
||||||
.map((mentionChunk, index) => {
|
.map((mentionChunk, index) => {
|
||||||
const mention = mentionChunk.match(App.MENTION_REGEX);
|
const mention = mentionChunk.match(App.MENTION_REGEX);
|
||||||
|
|
||||||
|
|
|
||||||
42
webclient/src/components/Message/useCardCallout.ts
Normal file
42
webclient/src/components/Message/useCardCallout.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { CardDTO, TokenDTO } from '@app/services';
|
||||||
|
|
||||||
|
export interface CardCallout {
|
||||||
|
card: CardDTO | null;
|
||||||
|
token: TokenDTO | null;
|
||||||
|
anchorEl: Element | null;
|
||||||
|
open: boolean;
|
||||||
|
handlePopoverOpen: (event: React.MouseEvent) => void;
|
||||||
|
handlePopoverClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCardCallout(name: string): CardCallout {
|
||||||
|
const [card, setCard] = useState<CardDTO | null>(null);
|
||||||
|
const [token, setToken] = useState<TokenDTO | null>(null);
|
||||||
|
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
|
||||||
|
|
||||||
|
useMemo(async () => {
|
||||||
|
const c = await CardDTO.get(name);
|
||||||
|
if (c) {
|
||||||
|
return setCard(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await TokenDTO.get(name);
|
||||||
|
if (t) {
|
||||||
|
return setToken(t);
|
||||||
|
}
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
const handlePopoverOpen = (event: React.MouseEvent) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopoverClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
|
return { card, token, anchorEl, open, handlePopoverOpen, handlePopoverClose };
|
||||||
|
}
|
||||||
33
webclient/src/components/Message/useMessage.ts
Normal file
33
webclient/src/components/Message/useMessage.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { App } from '@app/types';
|
||||||
|
|
||||||
|
export interface ParsedMessage {
|
||||||
|
name: string | null;
|
||||||
|
chunks: ReactNode[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChunkParser = (chunk: string, index: number) => ReactNode;
|
||||||
|
|
||||||
|
export function useParsedMessage(message: string, parseChunk: ChunkParser): ParsedMessage {
|
||||||
|
const [chunks, setChunks] = useState<ReactNode[] | null>(null);
|
||||||
|
const [name, setName] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const match = message.match(App.MESSAGE_SENDER_REGEX);
|
||||||
|
if (match) {
|
||||||
|
setName(match[1]);
|
||||||
|
}
|
||||||
|
setChunks(parseMessage(message, parseChunk));
|
||||||
|
}, [message, parseChunk]);
|
||||||
|
|
||||||
|
return { name, chunks };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMessage(message: string, parseChunk: ChunkParser): ReactNode[] {
|
||||||
|
return message
|
||||||
|
.replace(App.MESSAGE_SENDER_REGEX, '')
|
||||||
|
.split(App.CARD_CALLOUT_REGEX)
|
||||||
|
.filter((chunk) => !!chunk)
|
||||||
|
.map(parseChunk);
|
||||||
|
}
|
||||||
|
|
@ -1,53 +1,32 @@
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { NavLink, generatePath } from 'react-router-dom';
|
import { NavLink, generatePath } from 'react-router-dom';
|
||||||
|
|
||||||
import Menu from '@mui/material/Menu';
|
import Menu from '@mui/material/Menu';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
|
||||||
import { Images } from '@app/images';
|
import { Images } from '@app/images';
|
||||||
import { useWebClient } from '@app/hooks';
|
|
||||||
import { ServerSelectors } from '@app/store';
|
|
||||||
import { App, Data } from '@app/types';
|
import { App, Data } from '@app/types';
|
||||||
import { useAppSelector } from '@app/store';
|
|
||||||
|
import { useUserDisplay } from './useUserDisplay';
|
||||||
|
|
||||||
import './UserDisplay.css';
|
import './UserDisplay.css';
|
||||||
|
|
||||||
|
interface UserDisplayProps {
|
||||||
|
user: Data.ServerInfo_User;
|
||||||
|
}
|
||||||
|
|
||||||
const UserDisplay = ({ user }: UserDisplayProps) => {
|
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 { name, country } = user;
|
||||||
|
const {
|
||||||
const handleClick = (event) => {
|
position,
|
||||||
event.preventDefault();
|
isABuddy,
|
||||||
setPosition({ x: event.clientX + 2, y: event.clientY + 4 });
|
isIgnored,
|
||||||
};
|
handleClick,
|
||||||
|
handleClose,
|
||||||
const handleClose = () => setPosition(null);
|
onAddBuddy,
|
||||||
|
onRemoveBuddy,
|
||||||
const isABuddy = Boolean(buddyList[user.name]);
|
onAddIgnore,
|
||||||
const isIgnored = Boolean(ignoreList[user.name]);
|
onRemoveIgnore,
|
||||||
|
} = useUserDisplay(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();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="user-display">
|
<div className="user-display">
|
||||||
|
|
@ -87,8 +66,4 @@ const UserDisplay = ({ user }: UserDisplayProps) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface UserDisplayProps {
|
|
||||||
user: Data.ServerInfo_User;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UserDisplay;
|
export default UserDisplay;
|
||||||
|
|
|
||||||
62
webclient/src/components/UserDisplay/useUserDisplay.ts
Normal file
62
webclient/src/components/UserDisplay/useUserDisplay.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
|
import { ServerSelectors, useAppSelector } from '@app/store';
|
||||||
|
|
||||||
|
export interface UserDisplay {
|
||||||
|
position: { x: number; y: number } | null;
|
||||||
|
isABuddy: boolean;
|
||||||
|
isIgnored: boolean;
|
||||||
|
handleClick: (event: React.MouseEvent) => void;
|
||||||
|
handleClose: () => void;
|
||||||
|
onAddBuddy: () => void;
|
||||||
|
onRemoveBuddy: () => void;
|
||||||
|
onAddIgnore: () => void;
|
||||||
|
onRemoveIgnore: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserDisplay(userName: string): UserDisplay {
|
||||||
|
const buddyList = useAppSelector((state) => ServerSelectors.getBuddyList(state));
|
||||||
|
const ignoreList = useAppSelector((state) => ServerSelectors.getIgnoreList(state));
|
||||||
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const webClient = useWebClient();
|
||||||
|
|
||||||
|
const handleClick = (event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setPosition({ x: event.clientX + 2, y: event.clientY + 4 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => setPosition(null);
|
||||||
|
|
||||||
|
const isABuddy = Boolean(buddyList[userName]);
|
||||||
|
const isIgnored = Boolean(ignoreList[userName]);
|
||||||
|
|
||||||
|
const onAddBuddy = () => {
|
||||||
|
webClient.request.session.addToBuddyList(userName);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
const onRemoveBuddy = () => {
|
||||||
|
webClient.request.session.removeFromBuddyList(userName);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
const onAddIgnore = () => {
|
||||||
|
webClient.request.session.addToIgnoreList(userName);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
const onRemoveIgnore = () => {
|
||||||
|
webClient.request.session.removeFromIgnoreList(userName);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
position,
|
||||||
|
isABuddy,
|
||||||
|
isIgnored,
|
||||||
|
handleClick,
|
||||||
|
handleClose,
|
||||||
|
onAddBuddy,
|
||||||
|
onRemoveBuddy,
|
||||||
|
onAddIgnore,
|
||||||
|
onRemoveIgnore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
|
|
@ -6,49 +5,28 @@ import ListItemButton from '@mui/material/ListItemButton';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
|
|
||||||
import { UserDisplay, VirtualList, AuthGuard, LanguageDropdown } from '@app/components';
|
import { UserDisplay, VirtualList, AuthGuard, LanguageDropdown } from '@app/components';
|
||||||
import { useWebClient } from '@app/hooks';
|
|
||||||
import { ServerSelectors } from '@app/store';
|
|
||||||
import Layout from '../Layout/Layout';
|
import Layout from '../Layout/Layout';
|
||||||
import { useAppSelector } from '@app/store';
|
|
||||||
|
|
||||||
import AddToBuddies from './AddToBuddies';
|
import AddToBuddies from './AddToBuddies';
|
||||||
import AddToIgnore from './AddToIgnore';
|
import AddToIgnore from './AddToIgnore';
|
||||||
|
import { useAccount } from './useAccount';
|
||||||
|
|
||||||
import './Account.css';
|
import './Account.css';
|
||||||
|
|
||||||
const Account = () => {
|
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 { t } = useTranslation();
|
||||||
|
const {
|
||||||
const handleAddToBuddies = ({ userName }) => {
|
buddyList,
|
||||||
webClient.request.session.addToBuddyList(userName);
|
ignoreList,
|
||||||
};
|
serverName,
|
||||||
|
serverVersion,
|
||||||
const handleAddToIgnore = ({ userName }) => {
|
user,
|
||||||
webClient.request.session.addToIgnoreList(userName);
|
avatarUrl,
|
||||||
};
|
handleAddToBuddies,
|
||||||
|
handleAddToIgnore,
|
||||||
|
handleDisconnect,
|
||||||
|
} = useAccount();
|
||||||
|
const { country, realName, name, userLevel, accountageSecs } = user || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className="account">
|
<Layout className="account">
|
||||||
|
|
@ -105,11 +83,7 @@ const Account = () => {
|
||||||
<Paper className="account-details">
|
<Paper className="account-details">
|
||||||
<p>Server Name: {serverName}</p>
|
<p>Server Name: {serverName}</p>
|
||||||
<p>Server Version: {serverVersion}</p>
|
<p>Server Version: {serverVersion}</p>
|
||||||
<Button
|
<Button color="primary" variant="contained" onClick={handleDisconnect}>
|
||||||
color="primary"
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => webClient.request.authentication.disconnect()}
|
|
||||||
>
|
|
||||||
{t('Common.disconnect')}
|
{t('Common.disconnect')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
@ -119,7 +93,7 @@ const Account = () => {
|
||||||
</Paper>
|
</Paper>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Account;
|
export default Account;
|
||||||
|
|
|
||||||
65
webclient/src/containers/Account/useAccount.ts
Normal file
65
webclient/src/containers/Account/useAccount.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
|
import { ServerSelectors, useAppSelector } from '@app/store';
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
buddyList: any[];
|
||||||
|
ignoreList: any[];
|
||||||
|
serverName: string | undefined;
|
||||||
|
serverVersion: string | undefined;
|
||||||
|
user: any;
|
||||||
|
avatarUrl: string;
|
||||||
|
handleAddToBuddies: (args: { userName: string }) => void;
|
||||||
|
handleAddToIgnore: (args: { userName: string }) => void;
|
||||||
|
handleDisconnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccount(): Account {
|
||||||
|
const buddyList = useAppSelector((state) => ServerSelectors.getSortedBuddyList(state));
|
||||||
|
const ignoreList = useAppSelector((state) => ServerSelectors.getSortedIgnoreList(state));
|
||||||
|
const serverName = useAppSelector((state) => ServerSelectors.getName(state));
|
||||||
|
const serverVersion = useAppSelector((state) => ServerSelectors.getVersion(state));
|
||||||
|
const user = useAppSelector((state) => ServerSelectors.getUser(state));
|
||||||
|
const webClient = useWebClient();
|
||||||
|
const { avatarBmp } = user || {};
|
||||||
|
|
||||||
|
const avatarUrl = useMemo(() => {
|
||||||
|
if (!avatarBmp) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return URL.createObjectURL(new Blob([avatarBmp as BlobPart], { type: 'image/png' }));
|
||||||
|
}, [avatarBmp]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (avatarUrl) {
|
||||||
|
URL.revokeObjectURL(avatarUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [avatarUrl]);
|
||||||
|
|
||||||
|
const handleAddToBuddies = ({ userName }: { userName: string }) => {
|
||||||
|
webClient.request.session.addToBuddyList(userName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToIgnore = ({ userName }: { userName: string }) => {
|
||||||
|
webClient.request.session.addToIgnoreList(userName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = () => {
|
||||||
|
webClient.request.authentication.disconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
buddyList,
|
||||||
|
ignoreList,
|
||||||
|
serverName,
|
||||||
|
serverVersion,
|
||||||
|
user,
|
||||||
|
avatarUrl,
|
||||||
|
handleAddToBuddies,
|
||||||
|
handleAddToIgnore,
|
||||||
|
handleDisconnect,
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
91
webclient/src/containers/Game/useGame.ts
Normal file
91
webclient/src/containers/Game/useGame.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||||
|
|
||||||
|
import { createCardRegistry, type CardRegistry } from '@app/components';
|
||||||
|
import { useCurrentGame, useGameAccess, type CurrentGame, type GameAccess } from '@app/hooks';
|
||||||
|
import type { Data } from '@app/types';
|
||||||
|
|
||||||
|
import { useGameArrowInteractions, type GameArrowInteractions } from './useGameArrowInteractions';
|
||||||
|
import { useGameDialogs, type GameDialogs } from './useGameDialogs';
|
||||||
|
import { useGameDnd, type GameDnd } from './useGameDnd';
|
||||||
|
import { useGameLifecycleNavigation } from './useGameLifecycleNavigation';
|
||||||
|
import { useGameOpponentSelector, type GameOpponentSelector } from './useGameOpponentSelector';
|
||||||
|
|
||||||
|
export interface Game extends CurrentGame {
|
||||||
|
boardRef: RefObject<HTMLDivElement>;
|
||||||
|
cardRegistry: CardRegistry;
|
||||||
|
sensors: ReturnType<typeof useSensors>;
|
||||||
|
hoveredCard: Data.ServerInfo_Card | null;
|
||||||
|
setHoveredCard: (card: Data.ServerInfo_Card | null) => void;
|
||||||
|
isRotated: boolean;
|
||||||
|
toggleRotated: () => void;
|
||||||
|
localAccess: GameAccess;
|
||||||
|
opponentAccess: GameAccess;
|
||||||
|
deckSelectOpen: boolean;
|
||||||
|
opponents: GameOpponentSelector;
|
||||||
|
arrows: GameArrowInteractions;
|
||||||
|
dialogs: GameDialogs;
|
||||||
|
dnd: GameDnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGame(): Game {
|
||||||
|
const current = useCurrentGame();
|
||||||
|
const { gameId, game, localPlayer, isSpectator } = current;
|
||||||
|
|
||||||
|
useGameLifecycleNavigation(gameId);
|
||||||
|
|
||||||
|
const boardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const cardRegistry = useMemo(() => createCardRegistry(), []);
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor));
|
||||||
|
const [hoveredCard, setHoveredCard] = useState<Data.ServerInfo_Card | null>(null);
|
||||||
|
// View-only 90° rotation; local to this tab, mirrors desktop's
|
||||||
|
// Player::actRotateLocal which applies a QGraphicsView transform with no
|
||||||
|
// server call.
|
||||||
|
const [isRotated, setIsRotated] = useState(false);
|
||||||
|
const toggleRotated = useCallback(() => setIsRotated((prev) => !prev), []);
|
||||||
|
|
||||||
|
const opponents = useGameOpponentSelector(game);
|
||||||
|
const localAccess = useGameAccess(gameId, game?.localPlayerId);
|
||||||
|
const opponentAccess = useGameAccess(gameId, opponents.shownOpponentId);
|
||||||
|
|
||||||
|
const arrows = useGameArrowInteractions({ gameId, game, boardRef, cardRegistry });
|
||||||
|
const dialogs = useGameDialogs({
|
||||||
|
gameId,
|
||||||
|
game,
|
||||||
|
localPlayer,
|
||||||
|
localAccess,
|
||||||
|
isSpectator,
|
||||||
|
startPendingArrow: arrows.startPendingArrow,
|
||||||
|
startPendingAttach: arrows.startPendingAttach,
|
||||||
|
});
|
||||||
|
const dnd = useGameDnd({ gameId, onDragStart: arrows.cancelPendingOnDragStart });
|
||||||
|
|
||||||
|
// Explicit localPlayer null-check closes a window during reconnect where
|
||||||
|
// `game` is present but `players[localPlayerId]` is not yet populated
|
||||||
|
// (Event_GameStateChanged arrives after Event_GameJoined echo).
|
||||||
|
const deckSelectOpen =
|
||||||
|
game != null &&
|
||||||
|
localPlayer != null &&
|
||||||
|
!game.started &&
|
||||||
|
!current.isSpectator &&
|
||||||
|
!current.isJudge &&
|
||||||
|
!localPlayer.properties.readyStart;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
boardRef,
|
||||||
|
cardRegistry,
|
||||||
|
sensors,
|
||||||
|
hoveredCard,
|
||||||
|
setHoveredCard,
|
||||||
|
isRotated,
|
||||||
|
toggleRotated,
|
||||||
|
localAccess,
|
||||||
|
opponentAccess,
|
||||||
|
deckSelectOpen,
|
||||||
|
opponents,
|
||||||
|
arrows,
|
||||||
|
dialogs,
|
||||||
|
dnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
213
webclient/src/containers/Game/useGameArrowInteractions.spec.ts
Normal file
213
webclient/src/containers/Game/useGameArrowInteractions.spec.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
import { createRef } from 'react';
|
||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { createCardRegistry } from '../../components/Game/CardRegistry/CardRegistryContext';
|
||||||
|
import { combineReducers } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import { gamesReducer } from '../../store/game/game.reducer';
|
||||||
|
import { makeGameEntry, makePlayerEntry, makePlayerProperties } from '../../store/game/__mocks__/fixtures';
|
||||||
|
import type { GamesState } from '../../store/game/game.interfaces';
|
||||||
|
import { makeReduxWebClientHookWrapper } from '../../__test-utils__/makeHookWrapper';
|
||||||
|
import { App } from '../../types';
|
||||||
|
import { useGameArrowInteractions } from './useGameArrowInteractions';
|
||||||
|
|
||||||
|
function setup({ localPlayerId = 1 }: { localPlayerId?: number } = {}) {
|
||||||
|
const game = makeGameEntry({
|
||||||
|
localPlayerId,
|
||||||
|
players: {
|
||||||
|
[localPlayerId]: makePlayerEntry({
|
||||||
|
properties: makePlayerProperties({ playerId: localPlayerId }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const gamesState: GamesState = { games: { 1: { ...game, info: { ...game.info, gameId: 1 } } } };
|
||||||
|
|
||||||
|
const { Wrapper, webClient } = makeReduxWebClientHookWrapper({
|
||||||
|
reducer: combineReducers({ games: gamesReducer }),
|
||||||
|
preloadedState: { games: gamesState },
|
||||||
|
});
|
||||||
|
|
||||||
|
const boardRef = createRef<HTMLDivElement>();
|
||||||
|
const board = document.createElement('div');
|
||||||
|
board.getBoundingClientRect = () =>
|
||||||
|
({ left: 0, top: 0, right: 1000, bottom: 1000, width: 1000, height: 1000, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
|
||||||
|
(boardRef as { current: HTMLDivElement | null }).current = board;
|
||||||
|
|
||||||
|
const cardRegistry = createCardRegistry();
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() =>
|
||||||
|
useGameArrowInteractions({
|
||||||
|
gameId: 1,
|
||||||
|
game: { ...game, info: { ...game.info, gameId: 1 } },
|
||||||
|
boardRef,
|
||||||
|
cardRegistry,
|
||||||
|
}),
|
||||||
|
{ wrapper: Wrapper },
|
||||||
|
);
|
||||||
|
|
||||||
|
return { result, webClient, boardRef };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCardElement({
|
||||||
|
playerId,
|
||||||
|
zone,
|
||||||
|
cardId,
|
||||||
|
}: {
|
||||||
|
playerId: number;
|
||||||
|
zone: string;
|
||||||
|
cardId: number;
|
||||||
|
}): HTMLElement {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.setAttribute('data-card-id', String(cardId));
|
||||||
|
el.setAttribute('data-card-owner', String(playerId));
|
||||||
|
el.setAttribute('data-card-zone', zone);
|
||||||
|
document.body.appendChild(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireMouseEvent(type: string, init: Partial<MouseEventInit> = {}) {
|
||||||
|
window.dispatchEvent(new MouseEvent(type, { bubbles: true, ...init }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useGameArrowInteractions', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an arrow after right-click-drag past the 8px threshold', () => {
|
||||||
|
const { result, webClient } = setup();
|
||||||
|
const targetEl = makeCardElement({ playerId: 2, zone: App.ZoneName.TABLE, cardId: 99 });
|
||||||
|
const origElementFromPoint = document.elementFromPoint;
|
||||||
|
document.elementFromPoint = () => targetEl;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleBoardMouseDown({
|
||||||
|
button: 2,
|
||||||
|
clientX: 10,
|
||||||
|
clientY: 10,
|
||||||
|
target: makeCardElement({ playerId: 1, zone: App.ZoneName.TABLE, cardId: 5 }),
|
||||||
|
} as unknown as React.MouseEvent<HTMLDivElement>);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireMouseEvent('mousemove', { clientX: 30, clientY: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireMouseEvent('mouseup', { button: 2, clientX: 30, clientY: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(webClient.request.game.createArrow).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({
|
||||||
|
startPlayerId: 1,
|
||||||
|
startCardId: 5,
|
||||||
|
targetPlayerId: 2,
|
||||||
|
targetCardId: 99,
|
||||||
|
targetZone: App.ZoneName.TABLE,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
document.elementFromPoint = origElementFromPoint;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plays the card (moveCard) when dragging from HAND to a non-HAND target', () => {
|
||||||
|
const { result, webClient } = setup({ localPlayerId: 1 });
|
||||||
|
const targetEl = makeCardElement({ playerId: 2, zone: App.ZoneName.TABLE, cardId: 99 });
|
||||||
|
const origElementFromPoint = document.elementFromPoint;
|
||||||
|
document.elementFromPoint = () => targetEl;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleBoardMouseDown({
|
||||||
|
button: 2,
|
||||||
|
clientX: 0,
|
||||||
|
clientY: 0,
|
||||||
|
target: makeCardElement({ playerId: 1, zone: App.ZoneName.HAND, cardId: 5 }),
|
||||||
|
} as unknown as React.MouseEvent<HTMLDivElement>);
|
||||||
|
});
|
||||||
|
act(() => fireMouseEvent('mousemove', { clientX: 30, clientY: 30 }));
|
||||||
|
act(() => fireMouseEvent('mouseup', { button: 2, clientX: 30, clientY: 30 }));
|
||||||
|
|
||||||
|
expect(webClient.request.game.moveCard).toHaveBeenCalled();
|
||||||
|
expect(webClient.request.game.createArrow).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
document.elementFromPoint = origElementFromPoint;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send a request when the drop lands on the same card (cancel)', () => {
|
||||||
|
const { result, webClient } = setup();
|
||||||
|
const sameEl = makeCardElement({ playerId: 1, zone: App.ZoneName.TABLE, cardId: 5 });
|
||||||
|
const origElementFromPoint = document.elementFromPoint;
|
||||||
|
document.elementFromPoint = () => sameEl;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleBoardMouseDown({
|
||||||
|
button: 2,
|
||||||
|
clientX: 0,
|
||||||
|
clientY: 0,
|
||||||
|
target: sameEl,
|
||||||
|
} as unknown as React.MouseEvent<HTMLDivElement>);
|
||||||
|
});
|
||||||
|
act(() => fireMouseEvent('mousemove', { clientX: 30, clientY: 30 }));
|
||||||
|
act(() => fireMouseEvent('mouseup', { button: 2, clientX: 30, clientY: 30 }));
|
||||||
|
|
||||||
|
expect(webClient.request.game.createArrow).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
document.elementFromPoint = origElementFromPoint;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send a request when mouseup is below the drag threshold', () => {
|
||||||
|
const { result, webClient } = setup();
|
||||||
|
const targetEl = makeCardElement({ playerId: 2, zone: App.ZoneName.TABLE, cardId: 99 });
|
||||||
|
const origElementFromPoint = document.elementFromPoint;
|
||||||
|
document.elementFromPoint = () => targetEl;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleBoardMouseDown({
|
||||||
|
button: 2,
|
||||||
|
clientX: 10,
|
||||||
|
clientY: 10,
|
||||||
|
target: makeCardElement({ playerId: 1, zone: App.ZoneName.TABLE, cardId: 5 }),
|
||||||
|
} as unknown as React.MouseEvent<HTMLDivElement>);
|
||||||
|
});
|
||||||
|
act(() => fireMouseEvent('mouseup', { button: 2, clientX: 12, clientY: 12 }));
|
||||||
|
|
||||||
|
expect(webClient.request.game.createArrow).not.toHaveBeenCalled();
|
||||||
|
expect(webClient.request.game.moveCard).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
document.elementFromPoint = origElementFromPoint;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ESC cancels pending arrow state', () => {
|
||||||
|
const { result } = setup();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.startPendingArrow({ sourcePlayerId: 1, sourceZone: App.ZoneName.TABLE, sourceCardId: 5 });
|
||||||
|
});
|
||||||
|
expect(result.current.arrowSourceKey).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
|
});
|
||||||
|
expect(result.current.arrowSourceKey).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ESC does not cancel while a MUI dialog is open', () => {
|
||||||
|
const { result } = setup();
|
||||||
|
|
||||||
|
const dialog = document.createElement('div');
|
||||||
|
dialog.className = 'MuiDialog-root';
|
||||||
|
dialog.setAttribute('role', 'dialog');
|
||||||
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.startPendingArrow({ sourcePlayerId: 1, sourceZone: App.ZoneName.TABLE, sourceCardId: 5 });
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.arrowSourceKey).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
404
webclient/src/containers/Game/useGameArrowInteractions.ts
Normal file
404
webclient/src/containers/Game/useGameArrowInteractions.ts
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
import { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import type { CardRegistry } from '@app/components';
|
||||||
|
import { makeCardKey } from '@app/components';
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
|
import { App, Data, type Enriched } from '@app/types';
|
||||||
|
|
||||||
|
interface PendingArrow {
|
||||||
|
sourcePlayerId: number;
|
||||||
|
sourceZone: string;
|
||||||
|
sourceCardId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shares shape with PendingArrow today, but kept distinct so future
|
||||||
|
// protocol fields (e.g. desktop's attach-target coord hints) can diverge
|
||||||
|
// without a runtime switch.
|
||||||
|
interface PendingAttach {
|
||||||
|
sourcePlayerId: number;
|
||||||
|
sourceZone: string;
|
||||||
|
sourceCardId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArrowDragState {
|
||||||
|
sourcePlayerId: number;
|
||||||
|
sourceZone: string;
|
||||||
|
sourceCardId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
currentX: number;
|
||||||
|
currentY: number;
|
||||||
|
moved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArrowDragPreview {
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameArrowInteractions {
|
||||||
|
arrowSourceKey: string | null;
|
||||||
|
dragPreview: ArrowDragPreview | null;
|
||||||
|
handleBoardMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
|
handleCardClick: (
|
||||||
|
ownerPlayerId: number,
|
||||||
|
zone: string,
|
||||||
|
card: Data.ServerInfo_Card,
|
||||||
|
) => void;
|
||||||
|
handleCardDoubleClick: (sourceZone: string, card: Data.ServerInfo_Card) => void;
|
||||||
|
startPendingArrow: (source: PendingArrow) => void;
|
||||||
|
startPendingAttach: (source: PendingAttach) => void;
|
||||||
|
cancelPendingOnDragStart: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseGameArrowInteractionsArgs {
|
||||||
|
gameId: number | undefined;
|
||||||
|
game: Enriched.GameEntry | undefined;
|
||||||
|
boardRef: RefObject<HTMLDivElement>;
|
||||||
|
cardRegistry: CardRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrowColorForModifiers(e: {
|
||||||
|
ctrlKey: boolean;
|
||||||
|
altKey: boolean;
|
||||||
|
shiftKey: boolean;
|
||||||
|
}): App.ColorRGBA {
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
return App.ArrowColor.YELLOW;
|
||||||
|
}
|
||||||
|
if (e.altKey) {
|
||||||
|
return App.ArrowColor.BLUE;
|
||||||
|
}
|
||||||
|
if (e.shiftKey) {
|
||||||
|
return App.ArrowColor.GREEN;
|
||||||
|
}
|
||||||
|
return App.ArrowColor.RED;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARROW_DRAG_THRESHOLD_PX = 8;
|
||||||
|
|
||||||
|
export function useGameArrowInteractions({
|
||||||
|
gameId,
|
||||||
|
game,
|
||||||
|
boardRef,
|
||||||
|
cardRegistry,
|
||||||
|
}: UseGameArrowInteractionsArgs): GameArrowInteractions {
|
||||||
|
const webClient = useWebClient();
|
||||||
|
|
||||||
|
const [pendingArrow, setPendingArrow] = useState<PendingArrow | null>(null);
|
||||||
|
const [pendingAttach, setPendingAttach] = useState<PendingAttach | null>(null);
|
||||||
|
const [arrowDrag, setArrowDrag] = useState<ArrowDragState | null>(null);
|
||||||
|
const suppressNextContextMenuRef = useRef(false);
|
||||||
|
|
||||||
|
// ESC cancels a pending arrow OR attach (matches desktop). Suppress the
|
||||||
|
// cancel when a MUI dialog is open — the dialog's own ESC handler should
|
||||||
|
// win so the user isn't rug-pulled out of a modal form.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingArrow && !pendingAttach && !arrowDrag) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (document.querySelector('.MuiDialog-root[role="dialog"]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingArrow(null);
|
||||||
|
setPendingAttach(null);
|
||||||
|
setArrowDrag(null);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [pendingArrow, pendingAttach, arrowDrag]);
|
||||||
|
|
||||||
|
// Right-click-drag arrow-draw lifecycle: window-level mousemove + mouseup
|
||||||
|
// listeners that track the cursor and finalize on release.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!arrowDrag) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMove = (e: MouseEvent) => {
|
||||||
|
setArrowDrag((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const movedX = Math.abs(e.clientX - prev.startX);
|
||||||
|
const movedY = Math.abs(e.clientY - prev.startY);
|
||||||
|
const moved = prev.moved || movedX + movedY > ARROW_DRAG_THRESHOLD_PX;
|
||||||
|
return { ...prev, currentX: e.clientX, currentY: e.clientY, moved };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUp = (e: MouseEvent) => {
|
||||||
|
if (e.button !== 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const drag = arrowDrag;
|
||||||
|
if (!drag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const movedX = Math.abs(e.clientX - drag.startX);
|
||||||
|
const movedY = Math.abs(e.clientY - drag.startY);
|
||||||
|
const moved = drag.moved || movedX + movedY > ARROW_DRAG_THRESHOLD_PX;
|
||||||
|
setArrowDrag(null);
|
||||||
|
if (!moved || gameId == null) {
|
||||||
|
// Short right-click with no drag: let the contextmenu handler run
|
||||||
|
// (it will open the card menu).
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Any real drag suppresses the contextmenu event that follows mouseup.
|
||||||
|
suppressNextContextMenuRef.current = true;
|
||||||
|
|
||||||
|
const el = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-card-id]') as HTMLElement | null;
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetPlayerId = Number(el.getAttribute('data-card-owner'));
|
||||||
|
const targetZone = el.getAttribute('data-card-zone') ?? '';
|
||||||
|
const targetCardId = Number(el.getAttribute('data-card-id'));
|
||||||
|
if (!Number.isFinite(targetPlayerId) || !targetZone || !Number.isFinite(targetCardId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Same-card drops are cancellations.
|
||||||
|
if (
|
||||||
|
targetPlayerId === drag.sourcePlayerId &&
|
||||||
|
targetZone === drag.sourceZone &&
|
||||||
|
targetCardId === drag.sourceCardId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Desktop parity: dragging an arrow from a local-hand card to a target
|
||||||
|
// outside the hand auto-plays the card (card_item.cpp:243-250) — the
|
||||||
|
// card is moved to the battlefield before any arrow is drawn. The
|
||||||
|
// server re-keys the card id during the move, so we can't also send
|
||||||
|
// createArrow here; instead we resolve this drag as a play-card intent.
|
||||||
|
if (
|
||||||
|
drag.sourceZone === App.ZoneName.HAND &&
|
||||||
|
drag.sourcePlayerId === game?.localPlayerId &&
|
||||||
|
targetZone !== App.ZoneName.HAND
|
||||||
|
) {
|
||||||
|
webClient.request.game.moveCard(gameId, {
|
||||||
|
startPlayerId: drag.sourcePlayerId,
|
||||||
|
startZone: drag.sourceZone,
|
||||||
|
cardsToMove: { card: [{ cardId: drag.sourceCardId }] },
|
||||||
|
targetPlayerId: drag.sourcePlayerId,
|
||||||
|
targetZone: App.ZoneName.TABLE,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
isReversed: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.createArrow(gameId, {
|
||||||
|
startPlayerId: drag.sourcePlayerId,
|
||||||
|
startZone: drag.sourceZone,
|
||||||
|
startCardId: drag.sourceCardId,
|
||||||
|
targetPlayerId,
|
||||||
|
targetZone,
|
||||||
|
targetCardId,
|
||||||
|
arrowColor: arrowColorForModifiers(e),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMove);
|
||||||
|
window.addEventListener('mouseup', handleUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMove);
|
||||||
|
window.removeEventListener('mouseup', handleUp);
|
||||||
|
};
|
||||||
|
}, [arrowDrag, gameId, webClient, game?.localPlayerId]);
|
||||||
|
|
||||||
|
// Suppress the browser contextmenu event after a right-drag.
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (suppressNextContextMenuRef.current) {
|
||||||
|
e.preventDefault();
|
||||||
|
suppressNextContextMenuRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('contextmenu', handler);
|
||||||
|
return () => window.removeEventListener('contextmenu', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBoardMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.button !== 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = (e.target as HTMLElement).closest('[data-card-id]') as HTMLElement | null;
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sourcePlayerId = Number(el.getAttribute('data-card-owner'));
|
||||||
|
const sourceZone = el.getAttribute('data-card-zone') ?? '';
|
||||||
|
const sourceCardId = Number(el.getAttribute('data-card-id'));
|
||||||
|
if (!Number.isFinite(sourcePlayerId) || !sourceZone || !Number.isFinite(sourceCardId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setArrowDrag({
|
||||||
|
sourcePlayerId,
|
||||||
|
sourceZone,
|
||||||
|
sourceCardId,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
currentX: e.clientX,
|
||||||
|
currentY: e.clientY,
|
||||||
|
moved: false,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const arrowSourceKey = pendingArrow
|
||||||
|
? makeCardKey(pendingArrow.sourcePlayerId, pendingArrow.sourceZone, pendingArrow.sourceCardId)
|
||||||
|
: pendingAttach
|
||||||
|
? makeCardKey(pendingAttach.sourcePlayerId, pendingAttach.sourceZone, pendingAttach.sourceCardId)
|
||||||
|
: arrowDrag
|
||||||
|
? makeCardKey(arrowDrag.sourcePlayerId, arrowDrag.sourceZone, arrowDrag.sourceCardId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Convert arrowDrag's viewport coords → board-relative coords for the SVG
|
||||||
|
// preview line. Recomputed every render; cheap.
|
||||||
|
const dragPreview = useMemo<ArrowDragPreview | null>(() => {
|
||||||
|
if (!arrowDrag || !arrowDrag.moved) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const boardRect = boardRef.current?.getBoundingClientRect();
|
||||||
|
const sourceEl = cardRegistry.get(
|
||||||
|
makeCardKey(arrowDrag.sourcePlayerId, arrowDrag.sourceZone, arrowDrag.sourceCardId),
|
||||||
|
);
|
||||||
|
if (!boardRect || !sourceEl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sourceRect = sourceEl.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x1: sourceRect.left + sourceRect.width / 2 - boardRect.left,
|
||||||
|
y1: sourceRect.top + sourceRect.height / 2 - boardRect.top,
|
||||||
|
x2: arrowDrag.currentX - boardRect.left,
|
||||||
|
y2: arrowDrag.currentY - boardRect.top,
|
||||||
|
color: App.rgbaToCss(App.ArrowColor.RED),
|
||||||
|
};
|
||||||
|
}, [arrowDrag, cardRegistry, boardRef]);
|
||||||
|
|
||||||
|
const handleCardClick = useCallback(
|
||||||
|
(ownerPlayerId: number, zone: string, card: Data.ServerInfo_Card) => {
|
||||||
|
if (gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending-attach (from CardContextMenu "Attach to card…") takes
|
||||||
|
// precedence over pending-arrow because it was activated by a later menu
|
||||||
|
// action. Click on the pending source to cancel.
|
||||||
|
if (pendingAttach) {
|
||||||
|
if (
|
||||||
|
pendingAttach.sourcePlayerId === ownerPlayerId &&
|
||||||
|
pendingAttach.sourceZone === zone &&
|
||||||
|
pendingAttach.sourceCardId === card.id
|
||||||
|
) {
|
||||||
|
setPendingAttach(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.attachCard(gameId, {
|
||||||
|
startZone: pendingAttach.sourceZone,
|
||||||
|
cardId: pendingAttach.sourceCardId,
|
||||||
|
targetPlayerId: ownerPlayerId,
|
||||||
|
targetZone: zone,
|
||||||
|
targetCardId: card.id,
|
||||||
|
});
|
||||||
|
setPendingAttach(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingArrow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cancel if user re-clicks the pending source.
|
||||||
|
if (
|
||||||
|
pendingArrow.sourcePlayerId === ownerPlayerId &&
|
||||||
|
pendingArrow.sourceZone === zone &&
|
||||||
|
pendingArrow.sourceCardId === card.id
|
||||||
|
) {
|
||||||
|
setPendingArrow(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Desktop parity: arrow from local-hand → non-hand target auto-plays the
|
||||||
|
// card (card_item.cpp:243-250). The server re-keys the moved card id, so
|
||||||
|
// we resolve this as a play-card intent and drop the arrow command.
|
||||||
|
if (
|
||||||
|
pendingArrow.sourceZone === App.ZoneName.HAND &&
|
||||||
|
pendingArrow.sourcePlayerId === game?.localPlayerId &&
|
||||||
|
zone !== App.ZoneName.HAND
|
||||||
|
) {
|
||||||
|
webClient.request.game.moveCard(gameId, {
|
||||||
|
startPlayerId: pendingArrow.sourcePlayerId,
|
||||||
|
startZone: pendingArrow.sourceZone,
|
||||||
|
cardsToMove: { card: [{ cardId: pendingArrow.sourceCardId }] },
|
||||||
|
targetPlayerId: pendingArrow.sourcePlayerId,
|
||||||
|
targetZone: App.ZoneName.TABLE,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
isReversed: false,
|
||||||
|
});
|
||||||
|
setPendingArrow(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.createArrow(gameId, {
|
||||||
|
startPlayerId: pendingArrow.sourcePlayerId,
|
||||||
|
startZone: pendingArrow.sourceZone,
|
||||||
|
startCardId: pendingArrow.sourceCardId,
|
||||||
|
targetPlayerId: ownerPlayerId,
|
||||||
|
targetZone: zone,
|
||||||
|
targetCardId: card.id,
|
||||||
|
arrowColor: App.ArrowColor.RED,
|
||||||
|
});
|
||||||
|
setPendingArrow(null);
|
||||||
|
},
|
||||||
|
[gameId, game?.localPlayerId, pendingArrow, pendingAttach, webClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardDoubleClick = useCallback(
|
||||||
|
(sourceZone: string, card: Data.ServerInfo_Card) => {
|
||||||
|
if (sourceZone !== App.ZoneName.TABLE || gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Desktop's arrow drag owns the pointer while active; mirror that by
|
||||||
|
// short-circuiting tap-toggle while a pending arrow/attach is armed.
|
||||||
|
if (pendingArrow || pendingAttach) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.setCardAttr(gameId, {
|
||||||
|
zone: sourceZone,
|
||||||
|
cardId: card.id,
|
||||||
|
attribute: Data.CardAttribute.AttrTapped,
|
||||||
|
attrValue: card.tapped ? '0' : '1',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[gameId, pendingArrow, pendingAttach, webClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const startPendingArrow = useCallback((source: PendingArrow) => {
|
||||||
|
setPendingArrow(source);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startPendingAttach = useCallback((source: PendingAttach) => {
|
||||||
|
setPendingAttach(source);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelPendingOnDragStart = useCallback(() => {
|
||||||
|
setPendingArrow((prev) => (prev ? null : prev));
|
||||||
|
setPendingAttach((prev) => (prev ? null : prev));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
arrowSourceKey,
|
||||||
|
dragPreview,
|
||||||
|
handleBoardMouseDown,
|
||||||
|
handleCardClick,
|
||||||
|
handleCardDoubleClick,
|
||||||
|
startPendingArrow,
|
||||||
|
startPendingAttach,
|
||||||
|
cancelPendingOnDragStart,
|
||||||
|
};
|
||||||
|
}
|
||||||
730
webclient/src/containers/Game/useGameDialogs.ts
Normal file
730
webclient/src/containers/Game/useGameDialogs.ts
Normal file
|
|
@ -0,0 +1,730 @@
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { DEFAULT_DIE_COUNT, DEFAULT_DIE_SIDES, type SideboardPlanMove } from '@app/dialogs';
|
||||||
|
import { useWebClient, type GameAccess } from '@app/hooks';
|
||||||
|
import { App, Data, type Enriched } from '@app/types';
|
||||||
|
|
||||||
|
export interface AnchorPosition {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoneViewTarget {
|
||||||
|
playerId: number;
|
||||||
|
zoneName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardMenuState {
|
||||||
|
card: Data.ServerInfo_Card;
|
||||||
|
sourcePlayerId: number;
|
||||||
|
sourceZone: string;
|
||||||
|
anchorPosition: AnchorPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoneMenuState {
|
||||||
|
playerId: number;
|
||||||
|
zoneName: string;
|
||||||
|
anchorPosition: AnchorPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptState {
|
||||||
|
title: string;
|
||||||
|
label: string;
|
||||||
|
initialValue?: string;
|
||||||
|
helperText?: string;
|
||||||
|
validate?: (value: string) => string | null;
|
||||||
|
onSubmit: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevealState {
|
||||||
|
title: string;
|
||||||
|
zoneName: string;
|
||||||
|
zoneLabel: string;
|
||||||
|
showCountInput: boolean;
|
||||||
|
defaultCount: number;
|
||||||
|
onSubmit: (args: { targetPlayerId: number; topCards: number }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConcedeConfirm = 'concede' | 'unconcede' | null;
|
||||||
|
|
||||||
|
export interface StartPendingSource {
|
||||||
|
sourcePlayerId: number;
|
||||||
|
sourceZone: string;
|
||||||
|
sourceCardId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameDialogs {
|
||||||
|
// Card/zone/player/hand menus
|
||||||
|
cardMenu: CardMenuState | null;
|
||||||
|
zoneMenu: ZoneMenuState | null;
|
||||||
|
playerMenu: AnchorPosition | null;
|
||||||
|
handMenu: AnchorPosition | null;
|
||||||
|
closeCardMenu: () => void;
|
||||||
|
closeZoneMenu: () => void;
|
||||||
|
closePlayerMenu: () => void;
|
||||||
|
closeHandMenu: () => void;
|
||||||
|
handleCardContextMenu: (
|
||||||
|
sourcePlayerId: number,
|
||||||
|
sourceZone: string,
|
||||||
|
card: Data.ServerInfo_Card,
|
||||||
|
event: React.MouseEvent,
|
||||||
|
) => void;
|
||||||
|
handleZoneContextMenu: (
|
||||||
|
playerId: number,
|
||||||
|
zoneName: string,
|
||||||
|
event: React.MouseEvent,
|
||||||
|
) => void;
|
||||||
|
handlePlayerContextMenu: (event: React.MouseEvent) => void;
|
||||||
|
handleHandContextMenu: (event: React.MouseEvent) => void;
|
||||||
|
|
||||||
|
// Zone-view dialog stack
|
||||||
|
zoneViews: ZoneViewTarget[];
|
||||||
|
handleZoneClick: (playerId: number, zoneName: string) => void;
|
||||||
|
handleCloseZoneView: (playerId: number, zoneName: string) => void;
|
||||||
|
|
||||||
|
// Prompt dialog
|
||||||
|
prompt: PromptState | null;
|
||||||
|
closePrompt: () => void;
|
||||||
|
|
||||||
|
// Roll die dialog
|
||||||
|
rollDieOpen: boolean;
|
||||||
|
lastDieSides: number;
|
||||||
|
lastDieCount: number;
|
||||||
|
openRollDie: () => void;
|
||||||
|
closeRollDie: () => void;
|
||||||
|
handleRollDieSubmit: (args: { sides: number; count: number }) => void;
|
||||||
|
|
||||||
|
// Counter / token / sideboard / game info / concede
|
||||||
|
createCounterOpen: boolean;
|
||||||
|
openCreateCounter: () => void;
|
||||||
|
closeCreateCounter: () => void;
|
||||||
|
handleCreateCounterSubmit: (args: {
|
||||||
|
name: string;
|
||||||
|
color: { r: number; g: number; b: number; a: number };
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
createTokenOpen: boolean;
|
||||||
|
openCreateToken: () => void;
|
||||||
|
closeCreateToken: () => void;
|
||||||
|
handleCreateTokenSubmit: (args: {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
pt: string;
|
||||||
|
annotation: string;
|
||||||
|
destroyOnZoneChange: boolean;
|
||||||
|
faceDown: boolean;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
sideboardOpen: boolean;
|
||||||
|
openSideboard: () => void;
|
||||||
|
closeSideboard: () => void;
|
||||||
|
handleSideboardSubmit: (moveList: SideboardPlanMove[]) => void;
|
||||||
|
handleToggleSideboardLock: (locked: boolean) => void;
|
||||||
|
|
||||||
|
gameInfoOpen: boolean;
|
||||||
|
openGameInfo: () => void;
|
||||||
|
closeGameInfo: () => void;
|
||||||
|
|
||||||
|
concedeConfirm: ConcedeConfirm;
|
||||||
|
openConcede: () => void;
|
||||||
|
openUnconcede: () => void;
|
||||||
|
closeConcedeConfirm: () => void;
|
||||||
|
confirmConcede: () => void;
|
||||||
|
confirmUnconcede: () => void;
|
||||||
|
|
||||||
|
// Reveal-cards dialog
|
||||||
|
revealState: RevealState | null;
|
||||||
|
closeReveal: () => void;
|
||||||
|
|
||||||
|
// Card context menu action handlers
|
||||||
|
handleRequestSetPT: () => void;
|
||||||
|
handleRequestSetAnnotation: () => void;
|
||||||
|
handleRequestSetCardCounter: () => void;
|
||||||
|
handleRequestDrawArrow: () => void;
|
||||||
|
handleRequestAttach: () => void;
|
||||||
|
handleRequestMoveToLibraryAt: () => void;
|
||||||
|
|
||||||
|
// Zone context menu action handlers
|
||||||
|
handleRequestDrawN: () => void;
|
||||||
|
handleRequestDumpN: () => void;
|
||||||
|
handleRequestRevealTopN: () => void;
|
||||||
|
handleRequestRevealZone: () => void;
|
||||||
|
|
||||||
|
// Hand context menu action handlers
|
||||||
|
handleRequestChooseMulligan: () => void;
|
||||||
|
handleRequestRevealHand: () => void;
|
||||||
|
handleRequestRevealRandom: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseGameDialogsArgs {
|
||||||
|
gameId: number | undefined;
|
||||||
|
game: Enriched.GameEntry | undefined;
|
||||||
|
localPlayer: Enriched.PlayerEntry | undefined;
|
||||||
|
localAccess: GameAccess;
|
||||||
|
isSpectator: boolean;
|
||||||
|
startPendingArrow: (source: StartPendingSource) => void;
|
||||||
|
startPendingAttach: (source: StartPendingSource) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGameDialogs({
|
||||||
|
gameId,
|
||||||
|
game,
|
||||||
|
localPlayer,
|
||||||
|
localAccess,
|
||||||
|
isSpectator,
|
||||||
|
startPendingArrow,
|
||||||
|
startPendingAttach,
|
||||||
|
}: UseGameDialogsArgs): GameDialogs {
|
||||||
|
const webClient = useWebClient();
|
||||||
|
|
||||||
|
const [zoneViews, setZoneViews] = useState<ZoneViewTarget[]>([]);
|
||||||
|
const [cardMenu, setCardMenu] = useState<CardMenuState | null>(null);
|
||||||
|
const [zoneMenu, setZoneMenu] = useState<ZoneMenuState | null>(null);
|
||||||
|
const [prompt, setPrompt] = useState<PromptState | null>(null);
|
||||||
|
const [rollDieOpen, setRollDieOpen] = useState(false);
|
||||||
|
const [lastDieSides, setLastDieSides] = useState(DEFAULT_DIE_SIDES);
|
||||||
|
const [lastDieCount, setLastDieCount] = useState(DEFAULT_DIE_COUNT);
|
||||||
|
const [createCounterOpen, setCreateCounterOpen] = useState(false);
|
||||||
|
const [createTokenOpen, setCreateTokenOpen] = useState(false);
|
||||||
|
const [sideboardOpen, setSideboardOpen] = useState(false);
|
||||||
|
const [revealState, setRevealState] = useState<RevealState | null>(null);
|
||||||
|
const [playerMenu, setPlayerMenu] = useState<AnchorPosition | null>(null);
|
||||||
|
const [handMenu, setHandMenu] = useState<AnchorPosition | null>(null);
|
||||||
|
const [concedeConfirm, setConcedeConfirm] = useState<ConcedeConfirm>(null);
|
||||||
|
const [gameInfoOpen, setGameInfoOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleZoneClick = useCallback((playerId: number, zoneName: string) => {
|
||||||
|
setZoneViews((prev) => {
|
||||||
|
if (prev.some((v) => v.playerId === playerId && v.zoneName === zoneName)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [...prev, { playerId, zoneName }];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseZoneView = useCallback((playerId: number, zoneName: string) => {
|
||||||
|
setZoneViews((prev) =>
|
||||||
|
prev.filter((v) => !(v.playerId === playerId && v.zoneName === zoneName)),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCardContextMenu = useCallback(
|
||||||
|
(
|
||||||
|
sourcePlayerId: number,
|
||||||
|
sourceZone: string,
|
||||||
|
card: Data.ServerInfo_Card,
|
||||||
|
event: React.MouseEvent,
|
||||||
|
) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setZoneMenu(null);
|
||||||
|
setCardMenu({
|
||||||
|
card,
|
||||||
|
sourcePlayerId,
|
||||||
|
sourceZone,
|
||||||
|
anchorPosition: { top: event.clientY, left: event.clientX },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleZoneContextMenu = useCallback(
|
||||||
|
(playerId: number, zoneName: string, event: React.MouseEvent) => {
|
||||||
|
if (playerId !== game?.localPlayerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const supported =
|
||||||
|
zoneName === App.ZoneName.DECK ||
|
||||||
|
zoneName === App.ZoneName.GRAVE ||
|
||||||
|
zoneName === App.ZoneName.EXILE;
|
||||||
|
if (!supported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
setCardMenu(null);
|
||||||
|
setZoneMenu({
|
||||||
|
playerId,
|
||||||
|
zoneName,
|
||||||
|
anchorPosition: { top: event.clientY, left: event.clientX },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[game?.localPlayerId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePlayerContextMenu = useCallback(
|
||||||
|
(event: React.MouseEvent) => {
|
||||||
|
if (gameId == null || isSpectator || localAccess.canAct === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
setPlayerMenu({ top: event.clientY, left: event.clientX });
|
||||||
|
},
|
||||||
|
[gameId, isSpectator, localAccess.canAct],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHandContextMenu = useCallback(
|
||||||
|
(event: React.MouseEvent) => {
|
||||||
|
if (gameId == null || isSpectator || localAccess.canAct === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
setHandMenu({ top: event.clientY, left: event.clientX });
|
||||||
|
},
|
||||||
|
[gameId, isSpectator, localAccess.canAct],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRequestSetPT = useCallback(() => {
|
||||||
|
const menu = cardMenu;
|
||||||
|
if (!menu || gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPrompt({
|
||||||
|
title: 'Set power/toughness',
|
||||||
|
label: 'P/T (e.g. 3/3)',
|
||||||
|
initialValue: menu.card.pt ?? '',
|
||||||
|
onSubmit: (value) => {
|
||||||
|
webClient.request.game.setCardAttr(gameId, {
|
||||||
|
zone: menu.sourceZone,
|
||||||
|
cardId: menu.card.id,
|
||||||
|
attribute: Data.CardAttribute.AttrPT,
|
||||||
|
attrValue: value,
|
||||||
|
});
|
||||||
|
setPrompt(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [cardMenu, gameId, webClient]);
|
||||||
|
|
||||||
|
const handleRequestSetAnnotation = useCallback(() => {
|
||||||
|
const menu = cardMenu;
|
||||||
|
if (!menu || gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPrompt({
|
||||||
|
title: 'Set annotation',
|
||||||
|
label: 'Annotation',
|
||||||
|
initialValue: menu.card.annotation ?? '',
|
||||||
|
onSubmit: (value) => {
|
||||||
|
webClient.request.game.setCardAttr(gameId, {
|
||||||
|
zone: menu.sourceZone,
|
||||||
|
cardId: menu.card.id,
|
||||||
|
attribute: Data.CardAttribute.AttrAnnotation,
|
||||||
|
attrValue: value,
|
||||||
|
});
|
||||||
|
setPrompt(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [cardMenu, gameId, webClient]);
|
||||||
|
|
||||||
|
const handleRequestSetCardCounter = useCallback(() => {
|
||||||
|
const menu = cardMenu;
|
||||||
|
if (!menu || gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = menu.card.counterList.find((c) => c.id === 0);
|
||||||
|
setPrompt({
|
||||||
|
title: 'Set card counter',
|
||||||
|
label: 'Counter value',
|
||||||
|
initialValue: String(existing?.value ?? 0),
|
||||||
|
validate: (v) => (/^-?\d+$/.test(v) ? null : 'Enter an integer'),
|
||||||
|
onSubmit: (value) => {
|
||||||
|
webClient.request.game.setCardCounter(gameId, {
|
||||||
|
zone: menu.sourceZone,
|
||||||
|
cardId: menu.card.id,
|
||||||
|
counterId: 0,
|
||||||
|
counterValue: Number(value),
|
||||||
|
});
|
||||||
|
setPrompt(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [cardMenu, gameId, webClient]);
|
||||||
|
|
||||||
|
const handleRequestDrawArrow = useCallback(() => {
|
||||||
|
const menu = cardMenu;
|
||||||
|
if (!menu) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startPendingArrow({
|
||||||
|
sourcePlayerId: menu.sourcePlayerId,
|
||||||
|
sourceZone: menu.sourceZone,
|
||||||
|
sourceCardId: menu.card.id,
|
||||||
|
});
|
||||||
|
}, [cardMenu, startPendingArrow]);
|
||||||
|
|
||||||
|
const handleRequestAttach = useCallback(() => {
|
||||||
|
const menu = cardMenu;
|
||||||
|
if (!menu) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startPendingAttach({
|
||||||
|
sourcePlayerId: menu.sourcePlayerId,
|
||||||
|
sourceZone: menu.sourceZone,
|
||||||
|
sourceCardId: menu.card.id,
|
||||||
|
});
|
||||||
|
}, [cardMenu, startPendingAttach]);
|
||||||
|
|
||||||
|
const handleRequestMoveToLibraryAt = useCallback(() => {
|
||||||
|
const menu = cardMenu;
|
||||||
|
if (!menu || gameId == null || game == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Desktop prompts for a 1-indexed position into the library, then
|
||||||
|
// internally subtracts 1 for the protocol's 0-indexed x-coordinate.
|
||||||
|
setPrompt({
|
||||||
|
title: 'Move to library at position',
|
||||||
|
label: 'Position (1 = top)',
|
||||||
|
initialValue: '1',
|
||||||
|
validate: (v) => (/^[1-9]\d*$/.test(v) ? null : 'Enter a positive integer'),
|
||||||
|
onSubmit: (value) => {
|
||||||
|
webClient.request.game.moveCard(gameId, {
|
||||||
|
startPlayerId: menu.sourcePlayerId,
|
||||||
|
startZone: menu.sourceZone,
|
||||||
|
cardsToMove: { card: [{ cardId: menu.card.id }] },
|
||||||
|
targetPlayerId: game.localPlayerId,
|
||||||
|
targetZone: App.ZoneName.DECK,
|
||||||
|
x: Math.max(0, Number(value) - 1),
|
||||||
|
y: 0,
|
||||||
|
isReversed: false,
|
||||||
|
});
|
||||||
|
setPrompt(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [cardMenu, game, gameId, webClient]);
|
||||||
|
|
||||||
|
const handleRequestDrawN = useCallback(() => {
|
||||||
|
if (gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPrompt({
|
||||||
|
title: 'Draw N cards',
|
||||||
|
label: 'Number of cards',
|
||||||
|
initialValue: '1',
|
||||||
|
validate: (v) => (/^[1-9]\d*$/.test(v) ? null : 'Enter a positive integer'),
|
||||||
|
onSubmit: (value) => {
|
||||||
|
webClient.request.game.drawCards(gameId, { number: Number(value) });
|
||||||
|
setPrompt(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [gameId, webClient]);
|
||||||
|
|
||||||
|
const handleRequestDumpN = useCallback(() => {
|
||||||
|
if (gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPrompt({
|
||||||
|
title: 'Dump top N',
|
||||||
|
label: 'Number of cards',
|
||||||
|
initialValue: '1',
|
||||||
|
validate: (v) => (/^[1-9]\d*$/.test(v) ? null : 'Enter a positive integer'),
|
||||||
|
onSubmit: (value) => {
|
||||||
|
webClient.request.game.dumpZone(gameId, {
|
||||||
|
playerId: game!.localPlayerId,
|
||||||
|
zoneName: App.ZoneName.DECK,
|
||||||
|
numberCards: Number(value),
|
||||||
|
isReversed: false,
|
||||||
|
});
|
||||||
|
setPrompt(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [game, gameId, webClient]);
|
||||||
|
|
||||||
|
const handleRollDieSubmit = useCallback(
|
||||||
|
({ sides, count }: { sides: number; count: number }) => {
|
||||||
|
if (gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.rollDie(gameId, { sides, count });
|
||||||
|
setLastDieSides(sides);
|
||||||
|
setLastDieCount(count);
|
||||||
|
setRollDieOpen(false);
|
||||||
|
},
|
||||||
|
[gameId, webClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateCounterSubmit = useCallback(
|
||||||
|
({
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
color: { r: number; g: number; b: number; a: number };
|
||||||
|
}) => {
|
||||||
|
if (gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.createCounter(gameId, {
|
||||||
|
counterName: name,
|
||||||
|
counterColor: color,
|
||||||
|
radius: 1,
|
||||||
|
value: 0,
|
||||||
|
});
|
||||||
|
setCreateCounterOpen(false);
|
||||||
|
},
|
||||||
|
[gameId, webClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateTokenSubmit = useCallback(
|
||||||
|
(args: {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
pt: string;
|
||||||
|
annotation: string;
|
||||||
|
destroyOnZoneChange: boolean;
|
||||||
|
faceDown: boolean;
|
||||||
|
}) => {
|
||||||
|
if (gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.createToken(gameId, {
|
||||||
|
zone: App.ZoneName.TABLE,
|
||||||
|
cardName: args.name,
|
||||||
|
color: args.color,
|
||||||
|
pt: args.pt,
|
||||||
|
annotation: args.annotation,
|
||||||
|
destroyOnZoneChange: args.destroyOnZoneChange,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
faceDown: args.faceDown,
|
||||||
|
targetCardId: -1,
|
||||||
|
});
|
||||||
|
setCreateTokenOpen(false);
|
||||||
|
},
|
||||||
|
[gameId, webClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSideboardSubmit = useCallback(
|
||||||
|
(moveList: SideboardPlanMove[]) => {
|
||||||
|
if (gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.setSideboardPlan(gameId, { moveList });
|
||||||
|
setSideboardOpen(false);
|
||||||
|
},
|
||||||
|
[gameId, webClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleSideboardLock = useCallback(
|
||||||
|
(locked: boolean) => {
|
||||||
|
if (gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.setSideboardLock(gameId, { locked });
|
||||||
|
},
|
||||||
|
[gameId, webClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRequestChooseMulligan = useCallback(() => {
|
||||||
|
if (gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Desktop's DlgMulligan (player_actions.cpp actMulligan) accepts any
|
||||||
|
// integer in [-handSize, handSize + deckSize]. 0 and negative values are
|
||||||
|
// "relative to current hand size" — doMulligan computes
|
||||||
|
// `handSize + number` before dispatching. Seeding with the configured
|
||||||
|
// starting hand size (7) matches desktop's default.
|
||||||
|
const handSize = localPlayer?.zones[App.ZoneName.HAND]?.cardCount ?? 0;
|
||||||
|
const deckSize = localPlayer?.zones[App.ZoneName.DECK]?.cardCount ?? 0;
|
||||||
|
const min = -handSize;
|
||||||
|
const max = handSize + deckSize;
|
||||||
|
setPrompt({
|
||||||
|
title: 'Take mulligan',
|
||||||
|
label: 'New hand size',
|
||||||
|
initialValue: '7',
|
||||||
|
helperText: '0 and lower are in comparison to current hand size.',
|
||||||
|
validate: (v) => {
|
||||||
|
if (!/^-?\d+$/.test(v)) {
|
||||||
|
return 'Enter an integer.';
|
||||||
|
}
|
||||||
|
const n = Number(v);
|
||||||
|
if (n < min || n > max) {
|
||||||
|
return `Enter an integer between ${min} and ${max}.`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onSubmit: (value) => {
|
||||||
|
const input = Number(value);
|
||||||
|
const resolved = input < 1 ? handSize + input : input;
|
||||||
|
webClient.request.game.mulligan(gameId, { number: resolved });
|
||||||
|
setPrompt(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [gameId, localPlayer, webClient]);
|
||||||
|
|
||||||
|
const handleRequestRevealHand = useCallback(() => {
|
||||||
|
if (gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRevealState({
|
||||||
|
title: 'Reveal hand',
|
||||||
|
zoneName: App.ZoneName.HAND,
|
||||||
|
zoneLabel: 'Hand',
|
||||||
|
showCountInput: false,
|
||||||
|
defaultCount: 1,
|
||||||
|
onSubmit: ({ targetPlayerId }) => {
|
||||||
|
webClient.request.game.revealCards(gameId, {
|
||||||
|
zoneName: App.ZoneName.HAND,
|
||||||
|
playerId: targetPlayerId,
|
||||||
|
topCards: -1,
|
||||||
|
});
|
||||||
|
setRevealState(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [gameId, webClient]);
|
||||||
|
|
||||||
|
const handleRequestRevealRandom = useCallback(() => {
|
||||||
|
if (gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Desktop's RANDOM_CARD_FROM_ZONE sentinel (-2); see
|
||||||
|
// cockatrice/src/game/player/player_actions.h:47 and
|
||||||
|
// actRevealRandomHandCard at player_actions.cpp:1705-1712.
|
||||||
|
const RANDOM_CARD_FROM_ZONE = -2;
|
||||||
|
setRevealState({
|
||||||
|
title: 'Reveal random card',
|
||||||
|
zoneName: App.ZoneName.HAND,
|
||||||
|
zoneLabel: 'Hand (random)',
|
||||||
|
showCountInput: false,
|
||||||
|
defaultCount: 1,
|
||||||
|
onSubmit: ({ targetPlayerId }) => {
|
||||||
|
webClient.request.game.revealCards(gameId, {
|
||||||
|
zoneName: App.ZoneName.HAND,
|
||||||
|
cardId: [RANDOM_CARD_FROM_ZONE],
|
||||||
|
playerId: targetPlayerId,
|
||||||
|
topCards: -1,
|
||||||
|
});
|
||||||
|
setRevealState(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [gameId, webClient]);
|
||||||
|
|
||||||
|
const handleRequestRevealTopN = useCallback(() => {
|
||||||
|
if (gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRevealState({
|
||||||
|
title: 'Reveal top N cards',
|
||||||
|
zoneName: App.ZoneName.DECK,
|
||||||
|
zoneLabel: 'Library',
|
||||||
|
showCountInput: true,
|
||||||
|
defaultCount: 1,
|
||||||
|
onSubmit: ({ targetPlayerId, topCards }) => {
|
||||||
|
webClient.request.game.revealCards(gameId, {
|
||||||
|
zoneName: App.ZoneName.DECK,
|
||||||
|
playerId: targetPlayerId,
|
||||||
|
topCards,
|
||||||
|
});
|
||||||
|
setRevealState(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [gameId, webClient]);
|
||||||
|
|
||||||
|
const handleRequestRevealZone = useCallback(() => {
|
||||||
|
if (gameId == null || zoneMenu == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { zoneName } = zoneMenu;
|
||||||
|
const label =
|
||||||
|
zoneName === App.ZoneName.GRAVE ? 'Graveyard' :
|
||||||
|
zoneName === App.ZoneName.EXILE ? 'Exile' : zoneName;
|
||||||
|
setRevealState({
|
||||||
|
title: `Reveal ${label.toLowerCase()}`,
|
||||||
|
zoneName,
|
||||||
|
zoneLabel: label,
|
||||||
|
showCountInput: false,
|
||||||
|
defaultCount: 1,
|
||||||
|
onSubmit: ({ targetPlayerId }) => {
|
||||||
|
webClient.request.game.revealCards(gameId, {
|
||||||
|
zoneName,
|
||||||
|
playerId: targetPlayerId,
|
||||||
|
topCards: -1,
|
||||||
|
});
|
||||||
|
setRevealState(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [gameId, zoneMenu, webClient]);
|
||||||
|
|
||||||
|
const confirmConcede = useCallback(() => {
|
||||||
|
if (gameId != null) {
|
||||||
|
webClient.request.game.concede(gameId);
|
||||||
|
}
|
||||||
|
setConcedeConfirm(null);
|
||||||
|
}, [gameId, webClient]);
|
||||||
|
|
||||||
|
const confirmUnconcede = useCallback(() => {
|
||||||
|
if (gameId != null) {
|
||||||
|
webClient.request.game.unconcede(gameId);
|
||||||
|
}
|
||||||
|
setConcedeConfirm(null);
|
||||||
|
}, [gameId, webClient]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cardMenu,
|
||||||
|
zoneMenu,
|
||||||
|
playerMenu,
|
||||||
|
handMenu,
|
||||||
|
closeCardMenu: useCallback(() => setCardMenu(null), []),
|
||||||
|
closeZoneMenu: useCallback(() => setZoneMenu(null), []),
|
||||||
|
closePlayerMenu: useCallback(() => setPlayerMenu(null), []),
|
||||||
|
closeHandMenu: useCallback(() => setHandMenu(null), []),
|
||||||
|
handleCardContextMenu,
|
||||||
|
handleZoneContextMenu,
|
||||||
|
handlePlayerContextMenu,
|
||||||
|
handleHandContextMenu,
|
||||||
|
|
||||||
|
zoneViews,
|
||||||
|
handleZoneClick,
|
||||||
|
handleCloseZoneView,
|
||||||
|
|
||||||
|
prompt,
|
||||||
|
closePrompt: useCallback(() => setPrompt(null), []),
|
||||||
|
|
||||||
|
rollDieOpen,
|
||||||
|
lastDieSides,
|
||||||
|
lastDieCount,
|
||||||
|
openRollDie: useCallback(() => setRollDieOpen(true), []),
|
||||||
|
closeRollDie: useCallback(() => setRollDieOpen(false), []),
|
||||||
|
handleRollDieSubmit,
|
||||||
|
|
||||||
|
createCounterOpen,
|
||||||
|
openCreateCounter: useCallback(() => setCreateCounterOpen(true), []),
|
||||||
|
closeCreateCounter: useCallback(() => setCreateCounterOpen(false), []),
|
||||||
|
handleCreateCounterSubmit,
|
||||||
|
|
||||||
|
createTokenOpen,
|
||||||
|
openCreateToken: useCallback(() => setCreateTokenOpen(true), []),
|
||||||
|
closeCreateToken: useCallback(() => setCreateTokenOpen(false), []),
|
||||||
|
handleCreateTokenSubmit,
|
||||||
|
|
||||||
|
sideboardOpen,
|
||||||
|
openSideboard: useCallback(() => setSideboardOpen(true), []),
|
||||||
|
closeSideboard: useCallback(() => setSideboardOpen(false), []),
|
||||||
|
handleSideboardSubmit,
|
||||||
|
handleToggleSideboardLock,
|
||||||
|
|
||||||
|
gameInfoOpen,
|
||||||
|
openGameInfo: useCallback(() => setGameInfoOpen(true), []),
|
||||||
|
closeGameInfo: useCallback(() => setGameInfoOpen(false), []),
|
||||||
|
|
||||||
|
concedeConfirm,
|
||||||
|
openConcede: useCallback(() => setConcedeConfirm('concede'), []),
|
||||||
|
openUnconcede: useCallback(() => setConcedeConfirm('unconcede'), []),
|
||||||
|
closeConcedeConfirm: useCallback(() => setConcedeConfirm(null), []),
|
||||||
|
confirmConcede,
|
||||||
|
confirmUnconcede,
|
||||||
|
|
||||||
|
revealState,
|
||||||
|
closeReveal: useCallback(() => setRevealState(null), []),
|
||||||
|
|
||||||
|
handleRequestSetPT,
|
||||||
|
handleRequestSetAnnotation,
|
||||||
|
handleRequestSetCardCounter,
|
||||||
|
handleRequestDrawArrow,
|
||||||
|
handleRequestAttach,
|
||||||
|
handleRequestMoveToLibraryAt,
|
||||||
|
handleRequestDrawN,
|
||||||
|
handleRequestDumpN,
|
||||||
|
handleRequestRevealTopN,
|
||||||
|
handleRequestRevealZone,
|
||||||
|
handleRequestChooseMulligan,
|
||||||
|
handleRequestRevealHand,
|
||||||
|
handleRequestRevealRandom,
|
||||||
|
};
|
||||||
|
}
|
||||||
112
webclient/src/containers/Game/useGameDnd.ts
Normal file
112
webclient/src/containers/Game/useGameDnd.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
|
||||||
|
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
|
import { App, Data } from '@app/types';
|
||||||
|
|
||||||
|
export interface GameDnd {
|
||||||
|
activeCard: Data.ServerInfo_Card | null;
|
||||||
|
handleDragStart: (event: DragStartEvent) => void;
|
||||||
|
handleDragEnd: (event: DragEndEvent) => void;
|
||||||
|
handleDragCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseGameDndArgs {
|
||||||
|
gameId: number | undefined;
|
||||||
|
onDragStart: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGameDnd({ gameId, onDragStart }: UseGameDndArgs): GameDnd {
|
||||||
|
const webClient = useWebClient();
|
||||||
|
const [activeCard, setActiveCard] = useState<Data.ServerInfo_Card | null>(null);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(event: DragStartEvent) => {
|
||||||
|
const data = event.active.data.current as
|
||||||
|
| { card: Data.ServerInfo_Card }
|
||||||
|
| undefined;
|
||||||
|
setActiveCard(data?.card ?? null);
|
||||||
|
// Starting a drag cancels any armed pending-arrow or pending-attach —
|
||||||
|
// dnd-kit owns the pointer during the drag, matching desktop where the
|
||||||
|
// arrow draw from context menu is aborted if the user grabs a card.
|
||||||
|
onDragStart();
|
||||||
|
},
|
||||||
|
[onDragStart],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
setActiveCard(null);
|
||||||
|
if (!gameId || !event.over || !event.active.data.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const source = event.active.data.current as {
|
||||||
|
card: Data.ServerInfo_Card;
|
||||||
|
sourcePlayerId: number;
|
||||||
|
sourceZone: string;
|
||||||
|
};
|
||||||
|
const target = event.over.data.current as {
|
||||||
|
targetPlayerId: number;
|
||||||
|
targetZone: string;
|
||||||
|
row?: number;
|
||||||
|
attachTarget?: boolean;
|
||||||
|
targetCardId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drop onto another card on the table → attach source to target.
|
||||||
|
// Desktop's actAttach is only initiated from a table card, so source
|
||||||
|
// must also be TABLE. Non-TABLE drops onto a table card fall through
|
||||||
|
// to the normal moveCard branch (drop becomes "move to that row").
|
||||||
|
if (
|
||||||
|
target.attachTarget &&
|
||||||
|
target.targetCardId != null &&
|
||||||
|
source.sourceZone === App.ZoneName.TABLE
|
||||||
|
) {
|
||||||
|
// Guard no-op self-drop (source === target).
|
||||||
|
if (
|
||||||
|
source.sourcePlayerId === target.targetPlayerId &&
|
||||||
|
source.sourceZone === target.targetZone &&
|
||||||
|
source.card.id === target.targetCardId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.attachCard(gameId, {
|
||||||
|
startZone: source.sourceZone,
|
||||||
|
cardId: source.card.id,
|
||||||
|
targetPlayerId: target.targetPlayerId,
|
||||||
|
targetZone: target.targetZone,
|
||||||
|
targetCardId: target.targetCardId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameZone =
|
||||||
|
source.sourcePlayerId === target.targetPlayerId &&
|
||||||
|
source.sourceZone === target.targetZone;
|
||||||
|
if (sameZone && source.sourceZone === App.ZoneName.TABLE && (source.card.y ?? 0) === (target.row ?? 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sameZone && source.sourceZone !== App.ZoneName.TABLE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
webClient.request.game.moveCard(gameId, {
|
||||||
|
startPlayerId: source.sourcePlayerId,
|
||||||
|
startZone: source.sourceZone,
|
||||||
|
cardsToMove: { card: [{ cardId: source.card.id }] },
|
||||||
|
targetPlayerId: target.targetPlayerId,
|
||||||
|
targetZone: target.targetZone,
|
||||||
|
x: 0,
|
||||||
|
y: target.row ?? 0,
|
||||||
|
isReversed: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[gameId, webClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragCancel = useCallback(() => {
|
||||||
|
setActiveCard(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { activeCard, handleDragStart, handleDragEnd, handleDragCancel };
|
||||||
|
}
|
||||||
29
webclient/src/containers/Game/useGameLifecycleNavigation.ts
Normal file
29
webclient/src/containers/Game/useGameLifecycleNavigation.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useToast } from '@app/components';
|
||||||
|
import { useGameLifecycle } from '@app/hooks';
|
||||||
|
import { App } from '@app/types';
|
||||||
|
|
||||||
|
export function useGameLifecycleNavigation(gameId: number | undefined): void {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const kickedToast = useToast({
|
||||||
|
key: 'game-kicked',
|
||||||
|
children: 'You were kicked from the game',
|
||||||
|
});
|
||||||
|
const gameClosedToast = useToast({
|
||||||
|
key: 'game-closed',
|
||||||
|
children: 'The game was closed by the host',
|
||||||
|
});
|
||||||
|
|
||||||
|
useGameLifecycle(gameId, {
|
||||||
|
onKicked: () => {
|
||||||
|
kickedToast.openToast();
|
||||||
|
navigate(App.RouteEnum.SERVER);
|
||||||
|
},
|
||||||
|
onGameClosed: () => {
|
||||||
|
gameClosedToast.openToast();
|
||||||
|
navigate(App.RouteEnum.SERVER);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
53
webclient/src/containers/Game/useGameOpponentSelector.ts
Normal file
53
webclient/src/containers/Game/useGameOpponentSelector.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import type { Enriched } from '@app/types';
|
||||||
|
|
||||||
|
export interface OpponentEntry {
|
||||||
|
playerId: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameOpponentSelector {
|
||||||
|
opponents: OpponentEntry[];
|
||||||
|
selectedOpponentId: number | undefined;
|
||||||
|
setSelectedOpponentId: (id: number | undefined) => void;
|
||||||
|
shownOpponentId: number | undefined;
|
||||||
|
revealPlayers: OpponentEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGameOpponentSelector(
|
||||||
|
game: Enriched.GameEntry | undefined,
|
||||||
|
): GameOpponentSelector {
|
||||||
|
const [selectedOpponentId, setSelectedOpponentId] = useState<number | undefined>();
|
||||||
|
|
||||||
|
const opponents = useMemo<OpponentEntry[]>(() => {
|
||||||
|
if (!game) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.values(game.players)
|
||||||
|
.filter((p) => p.properties.playerId !== game.localPlayerId)
|
||||||
|
.map((p) => ({
|
||||||
|
playerId: p.properties.playerId,
|
||||||
|
name: p.properties.userInfo?.name ?? `p${p.properties.playerId}`,
|
||||||
|
}));
|
||||||
|
}, [game]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedOpponentId == null && opponents.length > 0) {
|
||||||
|
setSelectedOpponentId(opponents[0].playerId);
|
||||||
|
}
|
||||||
|
if (selectedOpponentId != null && !opponents.some((o) => o.playerId === selectedOpponentId)) {
|
||||||
|
setSelectedOpponentId(opponents[0]?.playerId);
|
||||||
|
}
|
||||||
|
}, [opponents, selectedOpponentId]);
|
||||||
|
|
||||||
|
const shownOpponentId = selectedOpponentId ?? opponents[0]?.playerId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
opponents,
|
||||||
|
selectedOpponentId,
|
||||||
|
setSelectedOpponentId,
|
||||||
|
shownOpponentId,
|
||||||
|
revealPlayers: opponents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import { NavLink, generatePath } from 'react-router-dom';
|
||||||
import { NavLink, useNavigate, generatePath } from 'react-router-dom';
|
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Menu from '@mui/material/Menu';
|
import Menu from '@mui/material/Menu';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
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 MenuRoundedIcon from '@mui/icons-material/MenuRounded';
|
||||||
|
|
||||||
import { CardImportDialog } from '@app/dialogs';
|
import { CardImportDialog } from '@app/dialogs';
|
||||||
import { useWebClient } from '@app/hooks';
|
|
||||||
import { Images } from '@app/images';
|
import { Images } from '@app/images';
|
||||||
import { RoomsSelectors, ServerSelectors } from '@app/store';
|
|
||||||
import { App } from '@app/types';
|
import { App } from '@app/types';
|
||||||
import { useAppSelector } from '@app/store';
|
|
||||||
|
import { useLeftNav } from './useLeftNav';
|
||||||
|
|
||||||
import './LeftNav.css';
|
import './LeftNav.css';
|
||||||
|
|
||||||
interface LeftNavState {
|
|
||||||
anchorEl: Element;
|
|
||||||
showCardImportDialog: boolean;
|
|
||||||
options: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const LeftNav = () => {
|
const LeftNav = () => {
|
||||||
const joinedRooms = useAppSelector(state => RoomsSelectors.getJoinedRooms(state));
|
const {
|
||||||
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
|
joinedRooms,
|
||||||
const isModerator = useAppSelector(ServerSelectors.getIsUserModerator);
|
isConnected,
|
||||||
const navigate = useNavigate();
|
state,
|
||||||
const webClient = useWebClient();
|
handleMenuOpen,
|
||||||
const [state, setState] = useState<LeftNavState>({
|
handleMenuItemClick,
|
||||||
anchorEl: null,
|
handleMenuClose,
|
||||||
showCardImportDialog: false,
|
leaveRoom,
|
||||||
options: [],
|
openImportCardWizard,
|
||||||
});
|
closeImportCardWizard,
|
||||||
|
} = useLeftNav();
|
||||||
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 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="LeftNav__container">
|
<div className="LeftNav__container">
|
||||||
|
|
@ -182,6 +131,6 @@ const LeftNav = () => {
|
||||||
></CardImportDialog>
|
></CardImportDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default LeftNav;
|
export default LeftNav;
|
||||||
|
|
|
||||||
93
webclient/src/containers/Layout/useLeftNav.ts
Normal file
93
webclient/src/containers/Layout/useLeftNav.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, generatePath } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
|
import { RoomsSelectors, ServerSelectors, useAppSelector } from '@app/store';
|
||||||
|
import { App } from '@app/types';
|
||||||
|
|
||||||
|
interface LeftNavState {
|
||||||
|
anchorEl: Element | null;
|
||||||
|
showCardImportDialog: boolean;
|
||||||
|
options: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeftNav {
|
||||||
|
joinedRooms: any[];
|
||||||
|
isConnected: boolean;
|
||||||
|
state: LeftNavState;
|
||||||
|
handleMenuOpen: (event: React.MouseEvent) => void;
|
||||||
|
handleMenuItemClick: (option: string) => void;
|
||||||
|
handleMenuClose: () => void;
|
||||||
|
leaveRoom: (event: React.MouseEvent, roomId: number) => void;
|
||||||
|
openImportCardWizard: () => void;
|
||||||
|
closeImportCardWizard: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLeftNav(): LeftNav {
|
||||||
|
const joinedRooms = useAppSelector((state) => RoomsSelectors.getJoinedRooms(state));
|
||||||
|
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
|
||||||
|
const isModerator = useAppSelector(ServerSelectors.getIsUserModerator);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const webClient = useWebClient();
|
||||||
|
const [state, setState] = useState<LeftNavState>({
|
||||||
|
anchorEl: null,
|
||||||
|
showCardImportDialog: false,
|
||||||
|
options: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let options: string[] = [
|
||||||
|
'Account',
|
||||||
|
'Replays',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isModerator) {
|
||||||
|
options = [
|
||||||
|
...options,
|
||||||
|
'Administration',
|
||||||
|
'Logs',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((s) => ({ ...s, options }));
|
||||||
|
}, [isModerator]);
|
||||||
|
|
||||||
|
const handleMenuOpen = (event: React.MouseEvent) => {
|
||||||
|
setState((s) => ({ ...s, anchorEl: event.target as Element }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuItemClick = (option: string) => {
|
||||||
|
const route = App.RouteEnum[option.toUpperCase()];
|
||||||
|
navigate(generatePath(route));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuClose = () => {
|
||||||
|
setState((s) => ({ ...s, anchorEl: null }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const leaveRoom = (event: React.MouseEvent, roomId: number) => {
|
||||||
|
event.preventDefault();
|
||||||
|
webClient.request.rooms.leaveRoom(roomId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openImportCardWizard = () => {
|
||||||
|
setState((s) => ({ ...s, showCardImportDialog: true }));
|
||||||
|
handleMenuClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeImportCardWizard = () => {
|
||||||
|
setState((s) => ({ ...s, showCardImportDialog: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedRooms,
|
||||||
|
isConnected,
|
||||||
|
state,
|
||||||
|
handleMenuOpen,
|
||||||
|
handleMenuItemClick,
|
||||||
|
handleMenuClose,
|
||||||
|
leaveRoom,
|
||||||
|
openImportCardWizard,
|
||||||
|
closeImportCardWizard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useState, useCallback, useRef } from 'react';
|
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Navigate } from 'react-router-dom';
|
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 { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog, AccountActivationDialog } from '@app/dialogs';
|
||||||
import { LanguageDropdown } from '@app/components';
|
import { LanguageDropdown } from '@app/components';
|
||||||
import { LoginForm } from '@app/forms';
|
import { LoginForm } from '@app/forms';
|
||||||
import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
|
||||||
import { Images } from '@app/images';
|
import { Images } from '@app/images';
|
||||||
import { getHostPort, serverProps } from '@app/services';
|
import { serverProps } from '@app/services';
|
||||||
import { App } from '@app/types';
|
import { App } from '@app/types';
|
||||||
import { WebsocketTypes } from '@app/websocket/types';
|
|
||||||
import { ServerSelectors, ServerTypes } from '@app/store';
|
|
||||||
import Layout from '../Layout/Layout';
|
import Layout from '../Layout/Layout';
|
||||||
import { useAppSelector } from '@app/store';
|
|
||||||
|
import { useLogin } from './useLogin';
|
||||||
|
|
||||||
import './Login.css';
|
import './Login.css';
|
||||||
import { useToast } from '@app/components';
|
|
||||||
|
|
||||||
const PREFIX = 'Login';
|
const PREFIX = 'Login';
|
||||||
|
|
||||||
const classes = {
|
const classes = {
|
||||||
root: `${PREFIX}-root`
|
root: `${PREFIX}-root`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const Root = styled('div')(({ theme }) => ({
|
const Root = styled('div')(({ theme }) => ({
|
||||||
[`&.${classes.root}`]: {
|
[`&.${classes.root}`]: {
|
||||||
'& .login-content__header': {
|
'& .login-content__header': {
|
||||||
color: theme.palette.success.light
|
color: theme.palette.success.light,
|
||||||
},
|
},
|
||||||
|
|
||||||
'& .login-content__description': {
|
'& .login-content__description': {
|
||||||
|
|
@ -61,183 +57,31 @@ const Root = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const Login = () => {
|
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 { t } = useTranslation();
|
||||||
|
const {
|
||||||
const [pendingActivationOptions, setPendingActivationOptions] = useState<WebsocketTypes.PendingActivationContext | null>(null);
|
description,
|
||||||
|
isConnected,
|
||||||
const rememberLoginRef = useRef<any>(null);
|
dialogState,
|
||||||
const knownHosts = useKnownHosts();
|
userToResetPassword,
|
||||||
const [dialogState, setDialogState] = useState({
|
submitButtonDisabled,
|
||||||
passwordResetRequestDialog: false,
|
handleLogin,
|
||||||
resetPasswordDialog: false,
|
showDescription,
|
||||||
registrationDialog: false,
|
handleRegistrationDialogSubmit,
|
||||||
activationDialog: false,
|
handleAccountActivationDialogSubmit,
|
||||||
});
|
handleRequestPasswordResetDialogSubmit,
|
||||||
const [userToResetPassword, setUserToResetPassword] = useState(null);
|
handleResetPasswordDialogSubmit,
|
||||||
|
skipTokenRequest,
|
||||||
const passwordResetToast = useToast({ key: 'password-reset-success', children: t('LoginContainer.toasts.passwordResetSuccess') });
|
closeRequestPasswordResetDialog,
|
||||||
const accountActivatedToast = useToast({
|
openRequestPasswordResetDialog,
|
||||||
key: 'account-activation-success',
|
closeResetPasswordDialog,
|
||||||
children: t('LoginContainer.toasts.accountActivationSuccess')
|
closeRegistrationDialog,
|
||||||
});
|
openRegistrationDialog,
|
||||||
|
closeActivateAccountDialog,
|
||||||
useReduxEffect(() => {
|
} = useLogin();
|
||||||
closeRequestPasswordResetDialog();
|
|
||||||
openResetPasswordDialog();
|
|
||||||
}, ServerTypes.RESET_PASSWORD_REQUESTED, []);
|
|
||||||
|
|
||||||
useReduxEffect(() => {
|
|
||||||
passwordResetToast.openToast()
|
|
||||||
closeResetPasswordDialog();
|
|
||||||
}, ServerTypes.RESET_PASSWORD_SUCCESS, []);
|
|
||||||
|
|
||||||
useReduxEffect(() => {
|
|
||||||
accountActivatedToast.openToast()
|
|
||||||
closeActivateAccountDialog();
|
|
||||||
setPendingActivationOptions(null);
|
|
||||||
}, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []);
|
|
||||||
|
|
||||||
useReduxEffect(({ payload: { options } }) => {
|
|
||||||
setPendingActivationOptions(options);
|
|
||||||
closeRegistrationDialog();
|
|
||||||
openActivateAccountDialog();
|
|
||||||
}, ServerTypes.ACCOUNT_AWAITING_ACTIVATION, []);
|
|
||||||
|
|
||||||
useReduxEffect(() => {
|
|
||||||
resetSubmitButton();
|
|
||||||
}, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []);
|
|
||||||
|
|
||||||
useReduxEffect(({ payload: { options: { hashedPassword } } }) => {
|
|
||||||
if (rememberLoginRef.current) {
|
|
||||||
updateHost(hashedPassword, rememberLoginRef.current);
|
|
||||||
}
|
|
||||||
}, ServerTypes.LOGIN_SUCCESSFUL, []);
|
|
||||||
|
|
||||||
const showDescription = () => {
|
|
||||||
return !isConnected && description?.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmitLogin = useCallback((loginForm) => {
|
|
||||||
rememberLoginRef.current = loginForm;
|
|
||||||
const { userName, password, selectedHost, remember } = loginForm;
|
|
||||||
|
|
||||||
const options: Omit<WebsocketTypes.LoginConnectOptions, 'reason'> = {
|
|
||||||
...getHostPort(selectedHost),
|
|
||||||
userName,
|
|
||||||
password,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (remember && !password) {
|
|
||||||
options.hashedPassword = selectedHost.hashedPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
webClient.request.authentication.login(options);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin);
|
|
||||||
|
|
||||||
useAutoLogin(handleLogin, connectionAttemptMade);
|
|
||||||
|
|
||||||
const updateHost = (hashedPassword, { selectedHost, remember, userName }) => {
|
|
||||||
knownHosts.update(selectedHost.id, {
|
|
||||||
remember,
|
|
||||||
userName: remember ? userName : null,
|
|
||||||
hashedPassword: remember ? hashedPassword : null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRegistrationDialogSubmit = (registerForm) => {
|
|
||||||
rememberLoginRef.current = registerForm;
|
|
||||||
const { userName, password, email, country, realName, selectedHost } = registerForm;
|
|
||||||
|
|
||||||
webClient.request.authentication.register({
|
|
||||||
...getHostPort(selectedHost),
|
|
||||||
userName,
|
|
||||||
password,
|
|
||||||
email,
|
|
||||||
country,
|
|
||||||
realName,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAccountActivationDialogSubmit = ({ token }) => {
|
|
||||||
if (!pendingActivationOptions) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
webClient.request.authentication.activateAccount({
|
|
||||||
host: pendingActivationOptions.host,
|
|
||||||
port: pendingActivationOptions.port,
|
|
||||||
userName: pendingActivationOptions.userName,
|
|
||||||
token,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRequestPasswordResetDialogSubmit = (form) => {
|
|
||||||
const { userName, email, selectedHost } = form;
|
|
||||||
const { host, port } = getHostPort(selectedHost);
|
|
||||||
|
|
||||||
if (email) {
|
|
||||||
webClient.request.authentication.resetPasswordChallenge({ userName, email, host, port });
|
|
||||||
} else {
|
|
||||||
setUserToResetPassword(userName);
|
|
||||||
webClient.request.authentication.resetPasswordRequest({ userName, host, port });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }) => {
|
|
||||||
const { host, port } = getHostPort(selectedHost);
|
|
||||||
|
|
||||||
webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port });
|
|
||||||
};
|
|
||||||
|
|
||||||
const skipTokenRequest = (userName) => {
|
|
||||||
setUserToResetPassword(userName);
|
|
||||||
|
|
||||||
setDialogState(s => ({ ...s,
|
|
||||||
passwordResetRequestDialog: false,
|
|
||||||
resetPasswordDialog: true,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeRequestPasswordResetDialog = () => {
|
|
||||||
setDialogState(s => ({ ...s, passwordResetRequestDialog: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const openRequestPasswordResetDialog = () => {
|
|
||||||
setDialogState(s => ({ ...s, passwordResetRequestDialog: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeResetPasswordDialog = () => {
|
|
||||||
setDialogState(s => ({ ...s, resetPasswordDialog: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const openResetPasswordDialog = () => {
|
|
||||||
setDialogState(s => ({ ...s, resetPasswordDialog: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeRegistrationDialog = () => {
|
|
||||||
setDialogState(s => ({ ...s, registrationDialog: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const openRegistrationDialog = () => {
|
|
||||||
setDialogState(s => ({ ...s, registrationDialog: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeActivateAccountDialog = () => {
|
|
||||||
setDialogState(s => ({ ...s, activationDialog: false }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const openActivateAccountDialog = () => {
|
|
||||||
setDialogState(s => ({ ...s, activationDialog: true }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout showNav={false} noHeightLimit={true}>
|
<Layout showNav={false} noHeightLimit={true}>
|
||||||
|
|
@ -261,13 +105,11 @@ const Login = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{showDescription() && (
|
||||||
showDescription() && (
|
|
||||||
<Paper className="login-content__connectionStatus">
|
<Paper className="login-content__connectionStatus">
|
||||||
{description}
|
{description}
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
|
|
||||||
<div className="login-footer">
|
<div className="login-footer">
|
||||||
<div className="login-footer__register">
|
<div className="login-footer__register">
|
||||||
|
|
@ -278,13 +120,11 @@ const Login = () => {
|
||||||
{t('LoginContainer.footer.credit')} - {new Date().getUTCFullYear()}
|
{t('LoginContainer.footer.credit')} - {new Date().getUTCFullYear()}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{
|
{serverProps.REACT_APP_VERSION && (
|
||||||
serverProps.REACT_APP_VERSION && (
|
|
||||||
<Typography variant="subtitle2">
|
<Typography variant="subtitle2">
|
||||||
{t('LoginContainer.footer.version')}: {serverProps.REACT_APP_VERSION}
|
{t('LoginContainer.footer.version')}: {serverProps.REACT_APP_VERSION}
|
||||||
</Typography>
|
</Typography>
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
|
|
||||||
<div className="login-footer__language">
|
<div className="login-footer__language">
|
||||||
<LanguageDropdown />
|
<LanguageDropdown />
|
||||||
|
|
@ -321,7 +161,6 @@ const Login = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ /*<img src={loginGraphic} className="login-content__description-image"/>*/}
|
|
||||||
<p className="login-content__description-subtitle1">{t('LoginContainer.content.subtitle1')}</p>
|
<p className="login-content__description-subtitle1">{t('LoginContainer.content.subtitle1')}</p>
|
||||||
<p className="login-content__description-subtitle2">{t('LoginContainer.content.subtitle2')}</p>
|
<p className="login-content__description-subtitle2">{t('LoginContainer.content.subtitle2')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -357,6 +196,6 @@ const Login = () => {
|
||||||
</Root>
|
</Root>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Login;
|
export default Login;
|
||||||
|
|
|
||||||
239
webclient/src/containers/Login/useLogin.ts
Normal file
239
webclient/src/containers/Login/useLogin.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useToast } from '@app/components';
|
||||||
|
import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
||||||
|
import { getHostPort } from '@app/services';
|
||||||
|
import { ServerSelectors, ServerTypes, useAppSelector } from '@app/store';
|
||||||
|
import { WebsocketTypes } from '@app/websocket/types';
|
||||||
|
|
||||||
|
export interface LoginDialogState {
|
||||||
|
passwordResetRequestDialog: boolean;
|
||||||
|
resetPasswordDialog: boolean;
|
||||||
|
registrationDialog: boolean;
|
||||||
|
activationDialog: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Login {
|
||||||
|
description: string | undefined;
|
||||||
|
isConnected: boolean;
|
||||||
|
pendingActivationOptions: WebsocketTypes.PendingActivationContext | null;
|
||||||
|
dialogState: LoginDialogState;
|
||||||
|
userToResetPassword: string | null;
|
||||||
|
submitButtonDisabled: boolean;
|
||||||
|
handleLogin: (form: any) => void;
|
||||||
|
showDescription: () => boolean;
|
||||||
|
handleRegistrationDialogSubmit: (form: any) => void;
|
||||||
|
handleAccountActivationDialogSubmit: (args: { token: string }) => void;
|
||||||
|
handleRequestPasswordResetDialogSubmit: (form: any) => void;
|
||||||
|
handleResetPasswordDialogSubmit: (args: any) => void;
|
||||||
|
skipTokenRequest: (userName: string) => void;
|
||||||
|
closeRequestPasswordResetDialog: () => void;
|
||||||
|
openRequestPasswordResetDialog: () => void;
|
||||||
|
closeResetPasswordDialog: () => void;
|
||||||
|
closeRegistrationDialog: () => void;
|
||||||
|
openRegistrationDialog: () => void;
|
||||||
|
closeActivateAccountDialog: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogin(): Login {
|
||||||
|
const description = useAppSelector((s) => ServerSelectors.getDescription(s));
|
||||||
|
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
|
||||||
|
const connectionAttemptMade = useAppSelector(ServerSelectors.getConnectionAttemptMade);
|
||||||
|
const webClient = useWebClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [pendingActivationOptions, setPendingActivationOptions] =
|
||||||
|
useState<WebsocketTypes.PendingActivationContext | null>(null);
|
||||||
|
|
||||||
|
const rememberLoginRef = useRef<any>(null);
|
||||||
|
const knownHosts = useKnownHosts();
|
||||||
|
const [dialogState, setDialogState] = useState<LoginDialogState>({
|
||||||
|
passwordResetRequestDialog: false,
|
||||||
|
resetPasswordDialog: false,
|
||||||
|
registrationDialog: false,
|
||||||
|
activationDialog: false,
|
||||||
|
});
|
||||||
|
const [userToResetPassword, setUserToResetPassword] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const passwordResetToast = useToast({
|
||||||
|
key: 'password-reset-success',
|
||||||
|
children: t('LoginContainer.toasts.passwordResetSuccess'),
|
||||||
|
});
|
||||||
|
const accountActivatedToast = useToast({
|
||||||
|
key: 'account-activation-success',
|
||||||
|
children: t('LoginContainer.toasts.accountActivationSuccess'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeRequestPasswordResetDialog = () => {
|
||||||
|
setDialogState((s) => ({ ...s, passwordResetRequestDialog: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRequestPasswordResetDialog = () => {
|
||||||
|
setDialogState((s) => ({ ...s, passwordResetRequestDialog: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeResetPasswordDialog = () => {
|
||||||
|
setDialogState((s) => ({ ...s, resetPasswordDialog: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openResetPasswordDialog = () => {
|
||||||
|
setDialogState((s) => ({ ...s, resetPasswordDialog: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRegistrationDialog = () => {
|
||||||
|
setDialogState((s) => ({ ...s, registrationDialog: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRegistrationDialog = () => {
|
||||||
|
setDialogState((s) => ({ ...s, registrationDialog: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeActivateAccountDialog = () => {
|
||||||
|
setDialogState((s) => ({ ...s, activationDialog: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openActivateAccountDialog = () => {
|
||||||
|
setDialogState((s) => ({ ...s, activationDialog: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
useReduxEffect(() => {
|
||||||
|
closeRequestPasswordResetDialog();
|
||||||
|
openResetPasswordDialog();
|
||||||
|
}, ServerTypes.RESET_PASSWORD_REQUESTED, []);
|
||||||
|
|
||||||
|
useReduxEffect(() => {
|
||||||
|
passwordResetToast.openToast();
|
||||||
|
closeResetPasswordDialog();
|
||||||
|
}, ServerTypes.RESET_PASSWORD_SUCCESS, []);
|
||||||
|
|
||||||
|
useReduxEffect(() => {
|
||||||
|
accountActivatedToast.openToast();
|
||||||
|
closeActivateAccountDialog();
|
||||||
|
setPendingActivationOptions(null);
|
||||||
|
}, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []);
|
||||||
|
|
||||||
|
useReduxEffect(({ payload: { options } }) => {
|
||||||
|
setPendingActivationOptions(options);
|
||||||
|
closeRegistrationDialog();
|
||||||
|
openActivateAccountDialog();
|
||||||
|
}, ServerTypes.ACCOUNT_AWAITING_ACTIVATION, []);
|
||||||
|
|
||||||
|
const onSubmitLogin = useCallback((loginForm) => {
|
||||||
|
rememberLoginRef.current = loginForm;
|
||||||
|
const { userName, password, selectedHost, remember } = loginForm;
|
||||||
|
|
||||||
|
const options: Omit<WebsocketTypes.LoginConnectOptions, 'reason'> = {
|
||||||
|
...getHostPort(selectedHost),
|
||||||
|
userName,
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (remember && !password) {
|
||||||
|
options.hashedPassword = selectedHost.hashedPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
webClient.request.authentication.login(options);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin);
|
||||||
|
|
||||||
|
useReduxEffect(() => {
|
||||||
|
resetSubmitButton();
|
||||||
|
}, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []);
|
||||||
|
|
||||||
|
const updateHost = (hashedPassword: string, { selectedHost, remember, userName }: any) => {
|
||||||
|
knownHosts.update(selectedHost.id, {
|
||||||
|
remember,
|
||||||
|
userName: remember ? userName : null,
|
||||||
|
hashedPassword: remember ? hashedPassword : null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useReduxEffect(({ payload: { options: { hashedPassword } } }) => {
|
||||||
|
if (rememberLoginRef.current) {
|
||||||
|
updateHost(hashedPassword, rememberLoginRef.current);
|
||||||
|
}
|
||||||
|
}, ServerTypes.LOGIN_SUCCESSFUL, []);
|
||||||
|
|
||||||
|
useAutoLogin(handleLogin, connectionAttemptMade);
|
||||||
|
|
||||||
|
const showDescription = () => {
|
||||||
|
return Boolean(!isConnected && description?.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegistrationDialogSubmit = (registerForm: any) => {
|
||||||
|
rememberLoginRef.current = registerForm;
|
||||||
|
const { userName, password, email, country, realName, selectedHost } = registerForm;
|
||||||
|
|
||||||
|
webClient.request.authentication.register({
|
||||||
|
...getHostPort(selectedHost),
|
||||||
|
userName,
|
||||||
|
password,
|
||||||
|
email,
|
||||||
|
country,
|
||||||
|
realName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccountActivationDialogSubmit = ({ token }: { token: string }) => {
|
||||||
|
if (!pendingActivationOptions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.authentication.activateAccount({
|
||||||
|
host: pendingActivationOptions.host,
|
||||||
|
port: pendingActivationOptions.port,
|
||||||
|
userName: pendingActivationOptions.userName,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequestPasswordResetDialogSubmit = (form: any) => {
|
||||||
|
const { userName, email, selectedHost } = form;
|
||||||
|
const { host, port } = getHostPort(selectedHost);
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
webClient.request.authentication.resetPasswordChallenge({ userName, email, host, port });
|
||||||
|
} else {
|
||||||
|
setUserToResetPassword(userName);
|
||||||
|
webClient.request.authentication.resetPasswordRequest({ userName, host, port });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }: any) => {
|
||||||
|
const { host, port } = getHostPort(selectedHost);
|
||||||
|
webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port });
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipTokenRequest = (userName: string) => {
|
||||||
|
setUserToResetPassword(userName);
|
||||||
|
|
||||||
|
setDialogState((s) => ({
|
||||||
|
...s,
|
||||||
|
passwordResetRequestDialog: false,
|
||||||
|
resetPasswordDialog: true,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
description,
|
||||||
|
isConnected,
|
||||||
|
pendingActivationOptions,
|
||||||
|
dialogState,
|
||||||
|
userToResetPassword,
|
||||||
|
submitButtonDisabled,
|
||||||
|
handleLogin,
|
||||||
|
showDescription,
|
||||||
|
handleRegistrationDialogSubmit,
|
||||||
|
handleAccountActivationDialogSubmit,
|
||||||
|
handleRequestPasswordResetDialogSubmit,
|
||||||
|
handleResetPasswordDialogSubmit,
|
||||||
|
skipTokenRequest,
|
||||||
|
closeRequestPasswordResetDialog,
|
||||||
|
openRequestPasswordResetDialog,
|
||||||
|
closeResetPasswordDialog,
|
||||||
|
closeRegistrationDialog,
|
||||||
|
openRegistrationDialog,
|
||||||
|
closeActivateAccountDialog,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import AppBar from '@mui/material/AppBar';
|
import AppBar from '@mui/material/AppBar';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
|
|
@ -12,6 +10,8 @@ import Tab from '@mui/material/Tab';
|
||||||
import Tabs from '@mui/material/Tabs';
|
import Tabs from '@mui/material/Tabs';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
|
import { useLogResults } from './useLogResults';
|
||||||
|
|
||||||
import './LogResults.css';
|
import './LogResults.css';
|
||||||
|
|
||||||
const LogResults = (props) => {
|
const LogResults = (props) => {
|
||||||
|
|
@ -21,31 +21,15 @@ const LogResults = (props) => {
|
||||||
const hasGameLogs = logs.game && logs.game.length;
|
const hasGameLogs = logs.game && logs.game.length;
|
||||||
const hasChatLogs = logs.chat && logs.chat.length;
|
const hasChatLogs = logs.chat && logs.chat.length;
|
||||||
|
|
||||||
const [value, setValue] = React.useState(0);
|
const { value, handleChange } = useLogResults();
|
||||||
|
|
||||||
const handleChange = (event, newValue) => {
|
|
||||||
setValue(newValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
const headerCells = [
|
const headerCells = [
|
||||||
{
|
{ label: 'Time' },
|
||||||
label: 'Time'
|
{ label: 'Sender Name' },
|
||||||
},
|
{ label: 'Sender IP' },
|
||||||
{
|
{ label: 'Message' },
|
||||||
label: 'Sender Name'
|
{ label: 'Target ID' },
|
||||||
},
|
{ label: 'Target Name' },
|
||||||
{
|
|
||||||
label: 'Sender IP'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Message'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Target ID'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Target Name'
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -67,7 +51,7 @@ const LogResults = (props) => {
|
||||||
<Results logs={logs.chat} headerCells={headerCells} />
|
<Results logs={logs.chat} headerCells={headerCells} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const a11yProps = index => {
|
const a11yProps = index => {
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,13 @@
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { AuthGuard, ModGuard } from '@app/components';
|
import { AuthGuard, ModGuard } from '@app/components';
|
||||||
import { SearchForm } from '@app/forms';
|
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 LogResults from './LogResults';
|
||||||
|
import { useLogs } from './useLogs';
|
||||||
|
|
||||||
import './Logs.css';
|
import './Logs.css';
|
||||||
|
|
||||||
const Logs = () => {
|
const Logs = () => {
|
||||||
const logs = useAppSelector(state => ServerSelectors.getLogs(state));
|
const { logs, onSubmit } = useLogs();
|
||||||
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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="moderator-logs overflow-scroll">
|
<div className="moderator-logs overflow-scroll">
|
||||||
|
|
@ -74,13 +26,3 @@ const Logs = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Logs;
|
export default Logs;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
16
webclient/src/containers/Logs/useLogResults.ts
Normal file
16
webclient/src/containers/Logs/useLogResults.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export interface LogResults {
|
||||||
|
value: number;
|
||||||
|
handleChange: (event: unknown, newValue: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogResults(): LogResults {
|
||||||
|
const [value, setValue] = useState(0);
|
||||||
|
|
||||||
|
const handleChange = (_event: unknown, newValue: number) => {
|
||||||
|
setValue(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { value, handleChange };
|
||||||
|
}
|
||||||
61
webclient/src/containers/Logs/useLogs.ts
Normal file
61
webclient/src/containers/Logs/useLogs.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
|
import { ServerDispatch, ServerSelectors, useAppSelector } from '@app/store';
|
||||||
|
import { Data } from '@app/types';
|
||||||
|
|
||||||
|
const MAXIMUM_RESULTS = 1000;
|
||||||
|
|
||||||
|
export interface Logs {
|
||||||
|
logs: any;
|
||||||
|
onSubmit: (fields: Data.ViewLogHistoryParams) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogs(): Logs {
|
||||||
|
const logs = useAppSelector((state) => ServerSelectors.getLogs(state));
|
||||||
|
const webClient = useWebClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
ServerDispatch.clearLogs();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const trimFields = (fields: any) => {
|
||||||
|
const result: any = {};
|
||||||
|
for (const [key, field] of Object.entries(fields)) {
|
||||||
|
if (typeof field === 'string') {
|
||||||
|
const trimmed = field.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
result[key] = trimmed;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result[key] = field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flattenLogLocations = (logLocations: any) => Object.keys(logLocations);
|
||||||
|
|
||||||
|
const onSubmit = (fields: Data.ViewLogHistoryParams) => {
|
||||||
|
const trimmedFields: any = trimFields(fields);
|
||||||
|
const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields;
|
||||||
|
|
||||||
|
const required = [userName, ipAddress, gameName, gameId, message].filter(Boolean);
|
||||||
|
|
||||||
|
if (logLocation) {
|
||||||
|
trimmedFields.logLocation = flattenLogLocations(logLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedFields.maximumResults = MAXIMUM_RESULTS;
|
||||||
|
|
||||||
|
if (required.length) {
|
||||||
|
webClient.request.moderator.viewLogHistory(trimmedFields);
|
||||||
|
} else {
|
||||||
|
// @TODO use yet-to-be-implemented banner/alert
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { logs, onSubmit };
|
||||||
|
}
|
||||||
|
|
@ -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 { create } from '@bufbuild/protobuf';
|
||||||
import {
|
import {
|
||||||
renderWithProviders,
|
renderWithProviders,
|
||||||
|
|
@ -241,12 +241,12 @@ describe('GameSelector', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
mockNavigate.mockClear();
|
mockNavigate.mockClear();
|
||||||
|
await act(async () => {
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: GameTypes.GAME_JOINED,
|
type: GameTypes.GAME_JOINED,
|
||||||
payload: { data: { gameInfo: { gameId: 42 }, hostId: 0, playerId: 0, spectator: false } },
|
payload: { data: { gameInfo: { gameId: 42 }, hostId: 0, playerId: 0, spectator: false } },
|
||||||
});
|
});
|
||||||
|
});
|
||||||
await Promise.resolve();
|
|
||||||
expect(mockNavigate).toHaveBeenCalledWith(App.RouteEnum.GAME);
|
expect(mockNavigate).toHaveBeenCalledWith(App.RouteEnum.GAME);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
// eslint-disable-next-line
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import Table from '@mui/material/Table';
|
import Table from '@mui/material/Table';
|
||||||
import TableBody from '@mui/material/TableBody';
|
import TableBody from '@mui/material/TableBody';
|
||||||
import TableCell from '@mui/material/TableCell';
|
import TableCell from '@mui/material/TableCell';
|
||||||
|
|
@ -9,11 +6,9 @@ import TableRow from '@mui/material/TableRow';
|
||||||
import TableSortLabel from '@mui/material/TableSortLabel';
|
import TableSortLabel from '@mui/material/TableSortLabel';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
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 { UserDisplay } from '@app/components';
|
||||||
import { useAppSelector } from '@app/store';
|
|
||||||
|
import { useGames } from './useGames';
|
||||||
|
|
||||||
import './Games.css';
|
import './Games.css';
|
||||||
|
|
||||||
|
|
@ -24,8 +19,7 @@ interface GamesProps {
|
||||||
|
|
||||||
const Games = ({ room }: GamesProps) => {
|
const Games = ({ room }: GamesProps) => {
|
||||||
const roomId = room.info.roomId;
|
const roomId = room.info.roomId;
|
||||||
const sortBy = useAppSelector(state => RoomsSelectors.getSortGamesBy(state));
|
const { sortBy, games, handleSort } = useGames(roomId);
|
||||||
const sortedGames = useAppSelector(state => RoomsSelectors.getSortedRoomGames(state, roomId));
|
|
||||||
|
|
||||||
const headerCells = [
|
const headerCells = [
|
||||||
{ label: 'Age', field: 'info.startTime' },
|
{ label: 'Age', field: 'info.startTime' },
|
||||||
|
|
@ -37,24 +31,6 @@ const Games = ({ room }: GamesProps) => {
|
||||||
{ label: 'Spectators', field: 'info.spectatorsCount' },
|
{ 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 (
|
return (
|
||||||
<div className="games">
|
<div className="games">
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import Table from '@mui/material/Table';
|
import Table from '@mui/material/Table';
|
||||||
import TableBody from '@mui/material/TableBody';
|
import TableBody from '@mui/material/TableBody';
|
||||||
import TableCell from '@mui/material/TableCell';
|
import TableCell from '@mui/material/TableCell';
|
||||||
|
|
@ -8,11 +6,11 @@ import TableRow from '@mui/material/TableRow';
|
||||||
import TableSortLabel from '@mui/material/TableSortLabel';
|
import TableSortLabel from '@mui/material/TableSortLabel';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
|
||||||
import { SortUtil, RoomsDispatch, RoomsSelectors } from '@app/store';
|
|
||||||
import { UserDisplay } from '@app/components';
|
import { UserDisplay } from '@app/components';
|
||||||
import { useAppSelector } from '@app/store';
|
|
||||||
import { Data, Enriched } from '@app/types';
|
import { Data, Enriched } from '@app/types';
|
||||||
|
|
||||||
|
import { useOpenGames } from './useOpenGames';
|
||||||
|
|
||||||
import './OpenGames.css';
|
import './OpenGames.css';
|
||||||
|
|
||||||
interface OpenGamesProps {
|
interface OpenGamesProps {
|
||||||
|
|
@ -56,9 +54,10 @@ function formatSpectators(info: Data.ServerInfo_Game): string {
|
||||||
|
|
||||||
const OpenGames = ({ room, onActivateGame }: OpenGamesProps) => {
|
const OpenGames = ({ room, onActivateGame }: OpenGamesProps) => {
|
||||||
const roomId = room.info.roomId;
|
const roomId = room.info.roomId;
|
||||||
const sortBy = useAppSelector(state => RoomsSelectors.getSortGamesBy(state));
|
const { sortBy, games, selectedGameId, handleSort, handleSelect, handleActivate } = useOpenGames({
|
||||||
const games = useAppSelector(state => RoomsSelectors.getFilteredRoomGames(state, roomId));
|
roomId,
|
||||||
const selectedGameId = useAppSelector(state => RoomsSelectors.getSelectedGameId(state, roomId));
|
onActivateGame,
|
||||||
|
});
|
||||||
|
|
||||||
const headerCells = [
|
const headerCells = [
|
||||||
{ label: 'Age', field: 'info.startTime' },
|
{ label: 'Age', field: 'info.startTime' },
|
||||||
|
|
@ -70,20 +69,6 @@ const OpenGames = ({ room, onActivateGame }: OpenGamesProps) => {
|
||||||
{ label: 'Spectators', field: 'info.spectatorsCount' },
|
{ 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 (
|
return (
|
||||||
<div className="games">
|
<div className="games">
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,23 @@
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import { useNavigate, useParams, generatePath } from 'react-router-dom';
|
|
||||||
|
|
||||||
import ListItemButton from '@mui/material/ListItemButton';
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
|
|
||||||
import { ScrollToBottomOnChanges, ThreePaneLayout, UserDisplay, VirtualList, AuthGuard } from '@app/components';
|
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 Layout from '../Layout/Layout';
|
||||||
|
|
||||||
import GameSelector from './GameSelector/GameSelector';
|
import GameSelector from './GameSelector/GameSelector';
|
||||||
import Messages from './Messages';
|
import Messages from './Messages';
|
||||||
import SayMessage from './SayMessage';
|
import SayMessage from './SayMessage';
|
||||||
|
import { useRoom } from './useRoom';
|
||||||
|
|
||||||
import './Room.css';
|
import './Room.css';
|
||||||
|
|
||||||
const Room = () => {
|
const Room = () => {
|
||||||
const joined = useAppSelector(state => RoomsSelectors.getJoinedRooms(state));
|
const { room, roomMessages, users, handleRoomSay } = useRoom();
|
||||||
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]);
|
|
||||||
|
|
||||||
if (!room) {
|
if (!room) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRoomSay = ({ message }) => {
|
|
||||||
if (message) {
|
|
||||||
webClient.request.rooms.roomSay(roomId, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className="room-view">
|
<Layout className="room-view">
|
||||||
<AuthGuard />
|
<AuthGuard />
|
||||||
|
|
@ -92,6 +64,6 @@ const Room = () => {
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Room;
|
export default Room;
|
||||||
|
|
|
||||||
33
webclient/src/containers/Room/useGames.ts
Normal file
33
webclient/src/containers/Room/useGames.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { SortUtil, RoomsDispatch, RoomsSelectors, useAppSelector } from '@app/store';
|
||||||
|
import { App } from '@app/types';
|
||||||
|
|
||||||
|
export interface Games {
|
||||||
|
sortBy: { field: string; order: string };
|
||||||
|
games: any[];
|
||||||
|
handleSort: (sortByField: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGames(roomId: number): Games {
|
||||||
|
const sortBy = useAppSelector((state) => RoomsSelectors.getSortGamesBy(state));
|
||||||
|
const sortedGames = useAppSelector((state) => RoomsSelectors.getSortedRoomGames(state, roomId));
|
||||||
|
|
||||||
|
const handleSort = (sortByField: string) => {
|
||||||
|
const { field, order } = SortUtil.toggleSortBy(sortByField as App.GameSortField, sortBy);
|
||||||
|
RoomsDispatch.sortGames(roomId, field, order);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUnavailableGame = ({ started, maxPlayers, playerCount }) =>
|
||||||
|
!started && playerCount < maxPlayers;
|
||||||
|
|
||||||
|
const isPasswordProtectedGame = ({ withPassword }) => !withPassword;
|
||||||
|
|
||||||
|
const isBuddiesOnlyGame = ({ onlyBuddies }) => !onlyBuddies;
|
||||||
|
|
||||||
|
const games = sortedGames.filter((game) => (
|
||||||
|
isUnavailableGame(game.info) &&
|
||||||
|
isPasswordProtectedGame(game.info) &&
|
||||||
|
isBuddiesOnlyGame(game.info)
|
||||||
|
));
|
||||||
|
|
||||||
|
return { sortBy, games, handleSort };
|
||||||
|
}
|
||||||
38
webclient/src/containers/Room/useOpenGames.ts
Normal file
38
webclient/src/containers/Room/useOpenGames.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { SortUtil, RoomsDispatch, RoomsSelectors, useAppSelector } from '@app/store';
|
||||||
|
import { App, Enriched } from '@app/types';
|
||||||
|
|
||||||
|
export interface OpenGames {
|
||||||
|
sortBy: { field: string; order: string };
|
||||||
|
games: Enriched.Game[];
|
||||||
|
selectedGameId: number | undefined;
|
||||||
|
handleSort: (sortByField: string) => void;
|
||||||
|
handleSelect: (gameId: number) => void;
|
||||||
|
handleActivate: (gameId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseOpenGamesArgs {
|
||||||
|
roomId: number;
|
||||||
|
onActivateGame?: (gameId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpenGames({ roomId, onActivateGame }: UseOpenGamesArgs): OpenGames {
|
||||||
|
const sortBy = useAppSelector((state) => RoomsSelectors.getSortGamesBy(state));
|
||||||
|
const games = useAppSelector((state) => RoomsSelectors.getFilteredRoomGames(state, roomId));
|
||||||
|
const selectedGameId = useAppSelector((state) => RoomsSelectors.getSelectedGameId(state, roomId));
|
||||||
|
|
||||||
|
const handleSort = (sortByField: string) => {
|
||||||
|
const { field, order } = SortUtil.toggleSortBy(sortByField as App.GameSortField, sortBy);
|
||||||
|
RoomsDispatch.sortGames(roomId, field, order);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (gameId: number) => {
|
||||||
|
RoomsDispatch.selectGame(roomId, gameId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivate = (gameId: number) => {
|
||||||
|
RoomsDispatch.selectGame(roomId, gameId);
|
||||||
|
onActivateGame?.(gameId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { sortBy, games, selectedGameId, handleSort, handleSelect, handleActivate };
|
||||||
|
}
|
||||||
42
webclient/src/containers/Room/useRoom.ts
Normal file
42
webclient/src/containers/Room/useRoom.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate, useParams, generatePath } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
|
import { RoomsSelectors, useAppSelector } from '@app/store';
|
||||||
|
import { App } from '@app/types';
|
||||||
|
|
||||||
|
export interface Room {
|
||||||
|
roomId: number;
|
||||||
|
room: any;
|
||||||
|
roomMessages: any;
|
||||||
|
users: any[];
|
||||||
|
handleRoomSay: (args: { message: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoom(): Room {
|
||||||
|
const joined = useAppSelector((state) => RoomsSelectors.getJoinedRooms(state));
|
||||||
|
const rooms = useAppSelector((state) => RoomsSelectors.getRooms(state));
|
||||||
|
const messages = useAppSelector((state) => RoomsSelectors.getMessages(state));
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
const roomId = parseInt(params.roomId, 10);
|
||||||
|
const room = rooms[roomId];
|
||||||
|
const roomMessages = messages[roomId];
|
||||||
|
const users = useAppSelector((state) => RoomsSelectors.getSortedRoomUsers(state, roomId));
|
||||||
|
const webClient = useWebClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!joined.find((r) => r.info.roomId === roomId)) {
|
||||||
|
navigate(generatePath(App.RouteEnum.SERVER));
|
||||||
|
}
|
||||||
|
}, [joined]);
|
||||||
|
|
||||||
|
const handleRoomSay = ({ message }: { message: string }) => {
|
||||||
|
if (message) {
|
||||||
|
webClient.request.rooms.roomSay(roomId, message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { roomId, room, roomMessages, users, handleRoomSay };
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
|
@ -11,6 +10,8 @@ import TextField from '@mui/material/TextField';
|
||||||
import { App } from '@app/types';
|
import { App } from '@app/types';
|
||||||
import { cx } from '@app/utils';
|
import { cx } from '@app/utils';
|
||||||
|
|
||||||
|
import { useCreateCounterDialog } from './useCreateCounterDialog';
|
||||||
|
|
||||||
import './CreateCounterDialog.css';
|
import './CreateCounterDialog.css';
|
||||||
|
|
||||||
const PREFIX = 'CreateCounterDialog';
|
const PREFIX = 'CreateCounterDialog';
|
||||||
|
|
@ -58,26 +59,8 @@ const SWATCHES: ReadonlyArray<Swatch> = [
|
||||||
];
|
];
|
||||||
|
|
||||||
function CreateCounterDialog({ isOpen, onSubmit, onCancel }: CreateCounterDialogProps) {
|
function CreateCounterDialog({ isOpen, onSubmit, onCancel }: CreateCounterDialogProps) {
|
||||||
const [name, setName] = useState('');
|
const { name, selectedIdx, error, handleNameChange, setSelectedIdx, handleSubmit } =
|
||||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
useCreateCounterDialog({ isOpen, swatches: SWATCHES, onSubmit });
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setName('');
|
|
||||||
setSelectedIdx(0);
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e?.preventDefault();
|
|
||||||
if (name.trim().length === 0) {
|
|
||||||
setError('Name is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSubmit({ name: name.trim(), color: SWATCHES[selectedIdx].color });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledDialog
|
<StyledDialog
|
||||||
|
|
@ -100,12 +83,7 @@ function CreateCounterDialog({ isOpen, onSubmit, onCancel }: CreateCounterDialog
|
||||||
size="small"
|
size="small"
|
||||||
label="Counter name"
|
label="Counter name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleNameChange(e.target.value)}
|
||||||
setName(e.target.value);
|
|
||||||
if (error) {
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
error={error != null}
|
error={error != null}
|
||||||
helperText={error ?? ''}
|
helperText={error ?? ''}
|
||||||
slotProps={{ htmlInput: { 'aria-label': 'Counter name' } }}
|
slotProps={{ htmlInput: { 'aria-label': 'Counter name' } }}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import type { CounterColor } from './CreateCounterDialog';
|
||||||
|
|
||||||
|
export interface CreateCounterDialogState {
|
||||||
|
name: string;
|
||||||
|
selectedIdx: number;
|
||||||
|
error: string | null;
|
||||||
|
handleNameChange: (value: string) => void;
|
||||||
|
setSelectedIdx: (idx: number) => void;
|
||||||
|
handleSubmit: (e?: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCreateCounterDialogArgs {
|
||||||
|
isOpen: boolean;
|
||||||
|
swatches: ReadonlyArray<{ color: CounterColor }>;
|
||||||
|
onSubmit: (args: { name: string; color: CounterColor }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateCounterDialog({
|
||||||
|
isOpen,
|
||||||
|
swatches,
|
||||||
|
onSubmit,
|
||||||
|
}: UseCreateCounterDialogArgs): CreateCounterDialogState {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setName('');
|
||||||
|
setSelectedIdx(0);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleNameChange = (value: string) => {
|
||||||
|
setName(value);
|
||||||
|
if (error) {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (name.trim().length === 0) {
|
||||||
|
setError('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit({ name: name.trim(), color: swatches[selectedIdx].color });
|
||||||
|
};
|
||||||
|
|
||||||
|
return { name, selectedIdx, error, handleNameChange, setSelectedIdx, handleSubmit };
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
|
@ -14,6 +13,13 @@ import MenuItem from '@mui/material/MenuItem';
|
||||||
import InputLabel from '@mui/material/InputLabel';
|
import InputLabel from '@mui/material/InputLabel';
|
||||||
import FormControl from '@mui/material/FormControl';
|
import FormControl from '@mui/material/FormControl';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MAX_ANNOTATION_LEN,
|
||||||
|
MAX_NAME_LEN,
|
||||||
|
MAX_PT_LEN,
|
||||||
|
useCreateTokenDialog,
|
||||||
|
} from './useCreateTokenDialog';
|
||||||
|
|
||||||
import './CreateTokenDialog.css';
|
import './CreateTokenDialog.css';
|
||||||
|
|
||||||
const PREFIX = 'CreateTokenDialog';
|
const PREFIX = 'CreateTokenDialog';
|
||||||
|
|
@ -58,51 +64,23 @@ const COLOR_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
|
||||||
{ value: '', label: 'Colorless' },
|
{ 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) {
|
function CreateTokenDialog({ isOpen, onSubmit, onCancel }: CreateTokenDialogProps) {
|
||||||
const [name, setName] = useState('');
|
const {
|
||||||
const [color, setColor] = useState(DEFAULT_COLOR);
|
name,
|
||||||
const [pt, setPT] = useState('');
|
|
||||||
const [annotation, setAnnotation] = useState('');
|
|
||||||
const [destroyOnZoneChange, setDestroyOnZoneChange] = useState(true);
|
|
||||||
const [faceDown, setFaceDown] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setName('');
|
|
||||||
setColor(DEFAULT_COLOR);
|
|
||||||
setPT('');
|
|
||||||
setAnnotation('');
|
|
||||||
setDestroyOnZoneChange(true);
|
|
||||||
setFaceDown(false);
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e?.preventDefault();
|
|
||||||
if (name.trim().length === 0) {
|
|
||||||
setError('Name is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSubmit({
|
|
||||||
name: name.trim(),
|
|
||||||
color,
|
color,
|
||||||
pt: pt.trim(),
|
pt,
|
||||||
annotation: annotation.trim(),
|
annotation,
|
||||||
destroyOnZoneChange,
|
destroyOnZoneChange,
|
||||||
faceDown,
|
faceDown,
|
||||||
});
|
error,
|
||||||
};
|
handleNameChange,
|
||||||
|
setColor,
|
||||||
|
setPT,
|
||||||
|
setAnnotation,
|
||||||
|
setDestroyOnZoneChange,
|
||||||
|
setFaceDown,
|
||||||
|
handleSubmit,
|
||||||
|
} = useCreateTokenDialog({ isOpen, onSubmit });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledDialog
|
<StyledDialog
|
||||||
|
|
@ -125,12 +103,7 @@ function CreateTokenDialog({ isOpen, onSubmit, onCancel }: CreateTokenDialogProp
|
||||||
size="small"
|
size="small"
|
||||||
label="Token name"
|
label="Token name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleNameChange(e.target.value)}
|
||||||
setName(e.target.value.slice(0, MAX_NAME_LEN));
|
|
||||||
if (error) {
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
error={error != null}
|
error={error != null}
|
||||||
helperText={error ?? ''}
|
helperText={error ?? ''}
|
||||||
slotProps={{ htmlInput: { 'aria-label': 'Token name', maxLength: MAX_NAME_LEN } }}
|
slotProps={{ htmlInput: { 'aria-label': 'Token name', maxLength: MAX_NAME_LEN } }}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import type { CreateTokenSubmit } from './CreateTokenDialog';
|
||||||
|
|
||||||
|
export interface CreateTokenDialogState {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
pt: string;
|
||||||
|
annotation: string;
|
||||||
|
destroyOnZoneChange: boolean;
|
||||||
|
faceDown: boolean;
|
||||||
|
error: string | null;
|
||||||
|
handleNameChange: (value: string) => void;
|
||||||
|
setColor: (value: string) => void;
|
||||||
|
setPT: (value: string) => void;
|
||||||
|
setAnnotation: (value: string) => void;
|
||||||
|
setDestroyOnZoneChange: (value: boolean) => void;
|
||||||
|
setFaceDown: (value: boolean) => void;
|
||||||
|
handleSubmit: (e?: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CREATE_TOKEN_DEFAULT_COLOR = 'w';
|
||||||
|
|
||||||
|
// Desktop server-side MAX_NAME_LENGTH is 0xff (255). Client-side caps on
|
||||||
|
// the other free-text fields mirror that to prevent oversize-payload
|
||||||
|
// round-trips that the server would reject anyway.
|
||||||
|
export const MAX_NAME_LEN = 255;
|
||||||
|
export const MAX_PT_LEN = 255;
|
||||||
|
export const MAX_ANNOTATION_LEN = 255;
|
||||||
|
|
||||||
|
export interface UseCreateTokenDialogArgs {
|
||||||
|
isOpen: boolean;
|
||||||
|
onSubmit: (args: CreateTokenSubmit) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateTokenDialog({
|
||||||
|
isOpen,
|
||||||
|
onSubmit,
|
||||||
|
}: UseCreateTokenDialogArgs): CreateTokenDialogState {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [color, setColor] = useState(CREATE_TOKEN_DEFAULT_COLOR);
|
||||||
|
const [pt, setPT] = useState('');
|
||||||
|
const [annotation, setAnnotation] = useState('');
|
||||||
|
const [destroyOnZoneChange, setDestroyOnZoneChange] = useState(true);
|
||||||
|
const [faceDown, setFaceDown] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setName('');
|
||||||
|
setColor(CREATE_TOKEN_DEFAULT_COLOR);
|
||||||
|
setPT('');
|
||||||
|
setAnnotation('');
|
||||||
|
setDestroyOnZoneChange(true);
|
||||||
|
setFaceDown(false);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleNameChange = (value: string) => {
|
||||||
|
setName(value.slice(0, MAX_NAME_LEN));
|
||||||
|
if (error) {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (name.trim().length === 0) {
|
||||||
|
setError('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit({
|
||||||
|
name: name.trim(),
|
||||||
|
color,
|
||||||
|
pt: pt.trim(),
|
||||||
|
annotation: annotation.trim(),
|
||||||
|
destroyOnZoneChange,
|
||||||
|
faceDown,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
pt,
|
||||||
|
annotation,
|
||||||
|
destroyOnZoneChange,
|
||||||
|
faceDown,
|
||||||
|
error,
|
||||||
|
handleNameChange,
|
||||||
|
setColor,
|
||||||
|
setPT,
|
||||||
|
setAnnotation,
|
||||||
|
setDestroyOnZoneChange,
|
||||||
|
setFaceDown,
|
||||||
|
handleSubmit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
|
@ -6,8 +5,7 @@ import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
|
|
||||||
import { useWebClient } from '@app/hooks';
|
import { useDeckSelectDialog } from './useDeckSelectDialog';
|
||||||
import { GameSelectors, useAppSelector } from '@app/store';
|
|
||||||
|
|
||||||
import './DeckSelectDialog.css';
|
import './DeckSelectDialog.css';
|
||||||
|
|
||||||
|
|
@ -40,34 +38,16 @@ export interface DeckSelectDialogProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeckSelectDialog({ isOpen, gameId, handleClose }: DeckSelectDialogProps) {
|
function DeckSelectDialog({ isOpen, gameId, handleClose }: DeckSelectDialogProps) {
|
||||||
const webClient = useWebClient();
|
const {
|
||||||
const localPlayer = useAppSelector((state) =>
|
deckText,
|
||||||
gameId != null ? GameSelectors.getLocalPlayer(state, gameId) : undefined,
|
setDeckText,
|
||||||
);
|
deckHash,
|
||||||
const [deckText, setDeckText] = useState('');
|
isReady,
|
||||||
|
canSubmit,
|
||||||
const deckHash = localPlayer?.properties.deckHash ?? '';
|
canToggleReady,
|
||||||
const isReady = localPlayer?.properties.readyStart ?? false;
|
handleSubmitDeck,
|
||||||
const hasLocalPlayer = localPlayer != null;
|
handleToggleReady,
|
||||||
// Guard Submit/Ready on having a local player — today the deckSelectOpen
|
} = useDeckSelectDialog(gameId);
|
||||||
// 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 (
|
return (
|
||||||
<StyledDialog
|
<StyledDialog
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useWebClient } from '@app/hooks';
|
||||||
|
import { GameSelectors, useAppSelector } from '@app/store';
|
||||||
|
|
||||||
|
export interface DeckSelectDialog {
|
||||||
|
deckText: string;
|
||||||
|
setDeckText: (v: string) => void;
|
||||||
|
deckHash: string;
|
||||||
|
isReady: boolean;
|
||||||
|
canSubmit: boolean;
|
||||||
|
canToggleReady: boolean;
|
||||||
|
handleSubmitDeck: () => void;
|
||||||
|
handleToggleReady: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeckSelectDialog(gameId: number | undefined): DeckSelectDialog {
|
||||||
|
const webClient = useWebClient();
|
||||||
|
const localPlayer = useAppSelector((state) =>
|
||||||
|
gameId != null ? GameSelectors.getLocalPlayer(state, gameId) : undefined,
|
||||||
|
);
|
||||||
|
const [deckText, setDeckText] = useState('');
|
||||||
|
|
||||||
|
const deckHash = localPlayer?.properties.deckHash ?? '';
|
||||||
|
const isReady = localPlayer?.properties.readyStart ?? false;
|
||||||
|
const hasLocalPlayer = localPlayer != null;
|
||||||
|
// Guard Submit/Ready on having a local player — today the deckSelectOpen
|
||||||
|
// predicate in Game.tsx implies one, but the dialog mounts before the
|
||||||
|
// Event_GameJoined echo populates players during reconnect.
|
||||||
|
const canSubmit = hasLocalPlayer && deckText.trim().length > 0;
|
||||||
|
const canToggleReady = hasLocalPlayer && deckHash.length > 0;
|
||||||
|
|
||||||
|
const handleSubmitDeck = () => {
|
||||||
|
if (!canSubmit || gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.deckSelect(gameId, { deck: deckText.trim() });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleReady = () => {
|
||||||
|
if (!canToggleReady || gameId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webClient.request.game.readyStart(gameId, { ready: !isReady });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
deckText,
|
||||||
|
setDeckText,
|
||||||
|
deckHash,
|
||||||
|
isReady,
|
||||||
|
canSubmit,
|
||||||
|
canToggleReady,
|
||||||
|
handleSubmitDeck,
|
||||||
|
handleToggleReady,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
|
@ -8,6 +7,8 @@ import Typography from '@mui/material/Typography';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
|
|
||||||
|
import { usePromptDialog } from './usePromptDialog';
|
||||||
|
|
||||||
import './PromptDialog.css';
|
import './PromptDialog.css';
|
||||||
|
|
||||||
const PREFIX = 'PromptDialog';
|
const PREFIX = 'PromptDialog';
|
||||||
|
|
@ -48,27 +49,12 @@ function PromptDialog({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: PromptDialogProps) {
|
}: PromptDialogProps) {
|
||||||
const [value, setValue] = useState(initialValue);
|
const { value, error, handleChange, handleSubmit } = usePromptDialog({
|
||||||
const [error, setError] = useState<string | null>(null);
|
isOpen,
|
||||||
|
initialValue,
|
||||||
useEffect(() => {
|
validate,
|
||||||
if (isOpen) {
|
onSubmit,
|
||||||
setValue(initialValue);
|
});
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}, [isOpen, initialValue]);
|
|
||||||
|
|
||||||
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e?.preventDefault();
|
|
||||||
if (validate) {
|
|
||||||
const message = validate(value);
|
|
||||||
if (message) {
|
|
||||||
setError(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onSubmit(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledDialog
|
<StyledDialog
|
||||||
|
|
@ -91,12 +77,7 @@ function PromptDialog({
|
||||||
size="small"
|
size="small"
|
||||||
label={label}
|
label={label}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
setValue(e.target.value);
|
|
||||||
if (error) {
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
error={error != null}
|
error={error != null}
|
||||||
helperText={error ?? helperText ?? ''}
|
helperText={error ?? helperText ?? ''}
|
||||||
slotProps={{ htmlInput: { 'aria-label': label } }}
|
slotProps={{ htmlInput: { 'aria-label': label } }}
|
||||||
|
|
|
||||||
53
webclient/src/dialogs/PromptDialog/usePromptDialog.ts
Normal file
53
webclient/src/dialogs/PromptDialog/usePromptDialog.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export interface PromptDialog {
|
||||||
|
value: string;
|
||||||
|
error: string | null;
|
||||||
|
handleChange: (v: string) => void;
|
||||||
|
handleSubmit: (e?: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePromptDialogArgs {
|
||||||
|
isOpen: boolean;
|
||||||
|
initialValue: string;
|
||||||
|
validate?: (value: string) => string | null;
|
||||||
|
onSubmit: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePromptDialog({
|
||||||
|
isOpen,
|
||||||
|
initialValue,
|
||||||
|
validate,
|
||||||
|
onSubmit,
|
||||||
|
}: UsePromptDialogArgs): PromptDialog {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setValue(initialValue);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [isOpen, initialValue]);
|
||||||
|
|
||||||
|
const handleChange = (v: string) => {
|
||||||
|
setValue(v);
|
||||||
|
if (error) {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (validate) {
|
||||||
|
const message = validate(value);
|
||||||
|
if (message) {
|
||||||
|
setError(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSubmit(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { value, error, handleChange, handleSubmit };
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
|
@ -12,6 +11,8 @@ import MenuItem from '@mui/material/MenuItem';
|
||||||
import InputLabel from '@mui/material/InputLabel';
|
import InputLabel from '@mui/material/InputLabel';
|
||||||
import FormControl from '@mui/material/FormControl';
|
import FormControl from '@mui/material/FormControl';
|
||||||
|
|
||||||
|
import { useRevealCardsDialog } from './useRevealCardsDialog';
|
||||||
|
|
||||||
import './RevealCardsDialog.css';
|
import './RevealCardsDialog.css';
|
||||||
|
|
||||||
const PREFIX = 'RevealCardsDialog';
|
const PREFIX = 'RevealCardsDialog';
|
||||||
|
|
@ -59,31 +60,14 @@ function RevealCardsDialog({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: RevealCardsDialogProps) {
|
}: RevealCardsDialogProps) {
|
||||||
const [targetPlayerId, setTargetPlayerId] = useState<number>(ALL_PLAYERS);
|
const {
|
||||||
const [countDraft, setCountDraft] = useState(String(defaultCount));
|
targetPlayerId,
|
||||||
const [error, setError] = useState<string | null>(null);
|
countDraft,
|
||||||
|
error,
|
||||||
useEffect(() => {
|
setTargetPlayerId,
|
||||||
if (isOpen) {
|
handleCountChange,
|
||||||
setTargetPlayerId(ALL_PLAYERS);
|
handleSubmit,
|
||||||
setCountDraft(String(defaultCount));
|
} = useRevealCardsDialog({ isOpen, showCountInput, defaultCount, onSubmit });
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}, [isOpen, defaultCount]);
|
|
||||||
|
|
||||||
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e?.preventDefault();
|
|
||||||
let topCards = -1;
|
|
||||||
if (showCountInput) {
|
|
||||||
const n = Number(countDraft);
|
|
||||||
if (!Number.isInteger(n) || n < 1) {
|
|
||||||
setError('Enter a positive integer');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
topCards = n;
|
|
||||||
}
|
|
||||||
onSubmit({ targetPlayerId, topCards });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledDialog
|
<StyledDialog
|
||||||
|
|
@ -128,12 +112,7 @@ function RevealCardsDialog({
|
||||||
type="number"
|
type="number"
|
||||||
label="How many?"
|
label="How many?"
|
||||||
value={countDraft}
|
value={countDraft}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleCountChange(e.target.value)}
|
||||||
setCountDraft(e.target.value);
|
|
||||||
if (error) {
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
error={error != null}
|
error={error != null}
|
||||||
helperText={error ?? 'Enter a positive integer'}
|
helperText={error ?? 'Enter a positive integer'}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import type { RevealCardsSubmit } from './RevealCardsDialog';
|
||||||
|
|
||||||
|
export interface RevealCardsDialogState {
|
||||||
|
targetPlayerId: number;
|
||||||
|
countDraft: string;
|
||||||
|
error: string | null;
|
||||||
|
setTargetPlayerId: (id: number) => void;
|
||||||
|
handleCountChange: (value: string) => void;
|
||||||
|
handleSubmit: (e?: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseRevealCardsDialogArgs {
|
||||||
|
isOpen: boolean;
|
||||||
|
showCountInput: boolean;
|
||||||
|
defaultCount: number;
|
||||||
|
onSubmit: (args: RevealCardsSubmit) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_PLAYERS = -1;
|
||||||
|
|
||||||
|
export function useRevealCardsDialog({
|
||||||
|
isOpen,
|
||||||
|
showCountInput,
|
||||||
|
defaultCount,
|
||||||
|
onSubmit,
|
||||||
|
}: UseRevealCardsDialogArgs): RevealCardsDialogState {
|
||||||
|
const [targetPlayerId, setTargetPlayerId] = useState<number>(ALL_PLAYERS);
|
||||||
|
const [countDraft, setCountDraft] = useState(String(defaultCount));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTargetPlayerId(ALL_PLAYERS);
|
||||||
|
setCountDraft(String(defaultCount));
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [isOpen, defaultCount]);
|
||||||
|
|
||||||
|
const handleCountChange = (value: string) => {
|
||||||
|
setCountDraft(value);
|
||||||
|
if (error) {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
let topCards = -1;
|
||||||
|
if (showCountInput) {
|
||||||
|
const n = Number(countDraft);
|
||||||
|
if (!Number.isInteger(n) || n < 1) {
|
||||||
|
setError('Enter a positive integer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
topCards = n;
|
||||||
|
}
|
||||||
|
onSubmit({ targetPlayerId, topCards });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetPlayerId,
|
||||||
|
countDraft,
|
||||||
|
error,
|
||||||
|
setTargetPlayerId,
|
||||||
|
handleCountChange,
|
||||||
|
handleSubmit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
|
@ -8,6 +7,8 @@ import Typography from '@mui/material/Typography';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
|
|
||||||
|
import { useRollDieDialog } from './useRollDieDialog';
|
||||||
|
|
||||||
import './RollDieDialog.css';
|
import './RollDieDialog.css';
|
||||||
|
|
||||||
const PREFIX = 'RollDieDialog';
|
const PREFIX = 'RollDieDialog';
|
||||||
|
|
@ -43,32 +44,8 @@ function RollDieDialog({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: RollDieDialogProps) {
|
}: RollDieDialogProps) {
|
||||||
const [sides, setSides] = useState(String(lastSides));
|
const { sides, count, error, handleSidesChange, handleCountChange, handleSubmit } =
|
||||||
const [count, setCount] = useState(String(lastCount));
|
useRollDieDialog({ isOpen, lastSides, lastCount, onSubmit });
|
||||||
const [error, setError] = useState<{ field: 'sides' | 'count'; message: string } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setSides(String(lastSides));
|
|
||||||
setCount(String(lastCount));
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}, [isOpen, lastSides, lastCount]);
|
|
||||||
|
|
||||||
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e?.preventDefault();
|
|
||||||
const s = Number(sides);
|
|
||||||
if (!Number.isInteger(s) || s < 1) {
|
|
||||||
setError({ field: 'sides', message: 'Enter an integer ≥ 1' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const c = Number(count);
|
|
||||||
if (!Number.isInteger(c) || c < 1) {
|
|
||||||
setError({ field: 'count', message: 'Enter an integer ≥ 1' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSubmit({ sides: s, count: c });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledDialog
|
<StyledDialog
|
||||||
|
|
@ -91,12 +68,7 @@ function RollDieDialog({
|
||||||
size="small"
|
size="small"
|
||||||
label="Sides"
|
label="Sides"
|
||||||
value={sides}
|
value={sides}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleSidesChange(e.target.value)}
|
||||||
setSides(e.target.value);
|
|
||||||
if (error) {
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
error={error?.field === 'sides'}
|
error={error?.field === 'sides'}
|
||||||
helperText={error?.field === 'sides' ? error.message : ''}
|
helperText={error?.field === 'sides' ? error.message : ''}
|
||||||
slotProps={{ htmlInput: { 'aria-label': 'Sides', inputMode: 'numeric' } }}
|
slotProps={{ htmlInput: { 'aria-label': 'Sides', inputMode: 'numeric' } }}
|
||||||
|
|
@ -107,12 +79,7 @@ function RollDieDialog({
|
||||||
size="small"
|
size="small"
|
||||||
label="Count"
|
label="Count"
|
||||||
value={count}
|
value={count}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleCountChange(e.target.value)}
|
||||||
setCount(e.target.value);
|
|
||||||
if (error) {
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
error={error?.field === 'count'}
|
error={error?.field === 'count'}
|
||||||
helperText={error?.field === 'count' ? error.message : ''}
|
helperText={error?.field === 'count' ? error.message : ''}
|
||||||
slotProps={{ htmlInput: { 'aria-label': 'Count', inputMode: 'numeric' } }}
|
slotProps={{ htmlInput: { 'aria-label': 'Count', inputMode: 'numeric' } }}
|
||||||
|
|
|
||||||
67
webclient/src/dialogs/RollDieDialog/useRollDieDialog.ts
Normal file
67
webclient/src/dialogs/RollDieDialog/useRollDieDialog.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export interface RollDieDialogState {
|
||||||
|
sides: string;
|
||||||
|
count: string;
|
||||||
|
error: { field: 'sides' | 'count'; message: string } | null;
|
||||||
|
handleSidesChange: (value: string) => void;
|
||||||
|
handleCountChange: (value: string) => void;
|
||||||
|
handleSubmit: (e?: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseRollDieDialogArgs {
|
||||||
|
isOpen: boolean;
|
||||||
|
lastSides: number;
|
||||||
|
lastCount: number;
|
||||||
|
onSubmit: (args: { sides: number; count: number }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRollDieDialog({
|
||||||
|
isOpen,
|
||||||
|
lastSides,
|
||||||
|
lastCount,
|
||||||
|
onSubmit,
|
||||||
|
}: UseRollDieDialogArgs): RollDieDialogState {
|
||||||
|
const [sides, setSides] = useState(String(lastSides));
|
||||||
|
const [count, setCount] = useState(String(lastCount));
|
||||||
|
const [error, setError] = useState<{ field: 'sides' | 'count'; message: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSides(String(lastSides));
|
||||||
|
setCount(String(lastCount));
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [isOpen, lastSides, lastCount]);
|
||||||
|
|
||||||
|
const handleSidesChange = (value: string) => {
|
||||||
|
setSides(value);
|
||||||
|
if (error) {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCountChange = (value: string) => {
|
||||||
|
setCount(value);
|
||||||
|
if (error) {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
const s = Number(sides);
|
||||||
|
if (!Number.isInteger(s) || s < 1) {
|
||||||
|
setError({ field: 'sides', message: 'Enter an integer ≥ 1' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const c = Number(count);
|
||||||
|
if (!Number.isInteger(c) || c < 1) {
|
||||||
|
setError({ field: 'count', message: 'Enter an integer ≥ 1' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit({ sides: s, count: c });
|
||||||
|
};
|
||||||
|
|
||||||
|
return { sides, count, error, handleSidesChange, handleCountChange, handleSubmit };
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
import { useRef, useState } from 'react';
|
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
|
||||||
import { useScryfallCard } from '@app/hooks';
|
import { useScryfallCard } from '@app/hooks';
|
||||||
import { GameSelectors, useAppSelector } from '@app/store';
|
|
||||||
import type { Data } from '@app/types';
|
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 {
|
export interface ZoneViewDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -19,19 +17,6 @@ export interface ZoneViewDialogProps {
|
||||||
initialPosition?: { x: number; y: number };
|
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 }) {
|
function ZoneThumbnail({ card }: { card: Data.ServerInfo_Card }) {
|
||||||
const { smallUrl } = useScryfallCard(card);
|
const { smallUrl } = useScryfallCard(card);
|
||||||
return (
|
return (
|
||||||
|
|
@ -57,71 +42,8 @@ function ZoneViewDialog({
|
||||||
handleClose,
|
handleClose,
|
||||||
initialPosition = DEFAULT_POSITION,
|
initialPosition = DEFAULT_POSITION,
|
||||||
}: ZoneViewDialogProps) {
|
}: ZoneViewDialogProps) {
|
||||||
const cards = useAppSelector((state) =>
|
const { cards, count, title, position, handlePointerDown, handlePointerMove, handlePointerUp } =
|
||||||
gameId != null && playerId != null && zoneName != null
|
useZoneViewDialog({ gameId, playerId, zoneName, initialPosition });
|
||||||
? GameSelectors.getCards(state, gameId, playerId, zoneName)
|
|
||||||
: EMPTY_CARDS,
|
|
||||||
);
|
|
||||||
const zone = useAppSelector((state) =>
|
|
||||||
gameId != null && playerId != null && zoneName != null
|
|
||||||
? GameSelectors.getZone(state, gameId, playerId, zoneName)
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
const playerName = useAppSelector((state) => {
|
|
||||||
if (gameId == null || playerId == null) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return GameSelectors.getPlayer(state, gameId, playerId)?.properties.userInfo?.name;
|
|
||||||
});
|
|
||||||
|
|
||||||
const count = zone?.cardCount ?? cards.length;
|
|
||||||
const title = `${playerName ?? ''} ${zoneLabel(zoneName)} (${count})`.trim();
|
|
||||||
|
|
||||||
// initialPosition is a caller-provided spawn point; we only honor it on mount.
|
|
||||||
// Later rerenders of the parent must not clobber a user's drag-positioned panel.
|
|
||||||
const [position, setPosition] = useState(initialPosition);
|
|
||||||
const dragStateRef = useRef<{
|
|
||||||
pointerId: number;
|
|
||||||
originX: number;
|
|
||||||
originY: number;
|
|
||||||
panelX: number;
|
|
||||||
panelY: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (e.button !== 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = e.currentTarget;
|
|
||||||
target.setPointerCapture(e.pointerId);
|
|
||||||
dragStateRef.current = {
|
|
||||||
pointerId: e.pointerId,
|
|
||||||
originX: e.clientX,
|
|
||||||
originY: e.clientY,
|
|
||||||
panelX: position.x,
|
|
||||||
panelY: position.y,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
const drag = dragStateRef.current;
|
|
||||||
if (!drag || e.pointerId !== drag.pointerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPosition({
|
|
||||||
x: drag.panelX + (e.clientX - drag.originX),
|
|
||||||
y: drag.panelY + (e.clientY - drag.originY),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
const drag = dragStateRef.current;
|
|
||||||
if (!drag || e.pointerId !== drag.pointerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
||||||
dragStateRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
111
webclient/src/dialogs/ZoneViewDialog/useZoneViewDialog.ts
Normal file
111
webclient/src/dialogs/ZoneViewDialog/useZoneViewDialog.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { GameSelectors, useAppSelector } from '@app/store';
|
||||||
|
import type { Data } from '@app/types';
|
||||||
|
|
||||||
|
const EMPTY_CARDS: Data.ServerInfo_Card[] = [];
|
||||||
|
|
||||||
|
export interface ZoneViewDialog {
|
||||||
|
cards: Data.ServerInfo_Card[];
|
||||||
|
count: number;
|
||||||
|
title: string;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
handlePointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||||
|
handlePointerMove: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||||
|
handlePointerUp: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseZoneViewDialogArgs {
|
||||||
|
gameId: number | undefined;
|
||||||
|
playerId: number | undefined;
|
||||||
|
zoneName: string | undefined;
|
||||||
|
initialPosition: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zoneLabel(zoneName: string | undefined): string {
|
||||||
|
switch (zoneName) {
|
||||||
|
case 'grave': return 'Graveyard';
|
||||||
|
case 'rfg': return 'Exile';
|
||||||
|
case 'deck': return 'Library';
|
||||||
|
case 'sb': return 'Sideboard';
|
||||||
|
case 'stack': return 'Stack';
|
||||||
|
case 'hand': return 'Hand';
|
||||||
|
case 'table': return 'Battlefield';
|
||||||
|
default: return zoneName ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useZoneViewDialog({
|
||||||
|
gameId,
|
||||||
|
playerId,
|
||||||
|
zoneName,
|
||||||
|
initialPosition,
|
||||||
|
}: UseZoneViewDialogArgs): ZoneViewDialog {
|
||||||
|
const cards = useAppSelector((state) =>
|
||||||
|
gameId != null && playerId != null && zoneName != null
|
||||||
|
? GameSelectors.getCards(state, gameId, playerId, zoneName)
|
||||||
|
: EMPTY_CARDS,
|
||||||
|
);
|
||||||
|
const zone = useAppSelector((state) =>
|
||||||
|
gameId != null && playerId != null && zoneName != null
|
||||||
|
? GameSelectors.getZone(state, gameId, playerId, zoneName)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const playerName = useAppSelector((state) => {
|
||||||
|
if (gameId == null || playerId == null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return GameSelectors.getPlayer(state, gameId, playerId)?.properties.userInfo?.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = zone?.cardCount ?? cards.length;
|
||||||
|
const title = `${playerName ?? ''} ${zoneLabel(zoneName)} (${count})`.trim();
|
||||||
|
|
||||||
|
// initialPosition is a caller-provided spawn point; we only honor it on mount.
|
||||||
|
// Later rerenders of the parent must not clobber a user's drag-positioned panel.
|
||||||
|
const [position, setPosition] = useState(initialPosition);
|
||||||
|
const dragStateRef = useRef<{
|
||||||
|
pointerId: number;
|
||||||
|
originX: number;
|
||||||
|
originY: number;
|
||||||
|
panelX: number;
|
||||||
|
panelY: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (e.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.setPointerCapture(e.pointerId);
|
||||||
|
dragStateRef.current = {
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
originX: e.clientX,
|
||||||
|
originY: e.clientY,
|
||||||
|
panelX: position.x,
|
||||||
|
panelY: position.y,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const drag = dragStateRef.current;
|
||||||
|
if (!drag || e.pointerId !== drag.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPosition({
|
||||||
|
x: drag.panelX + (e.clientX - drag.originX),
|
||||||
|
y: drag.panelY + (e.clientY - drag.originY),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const drag = dragStateRef.current;
|
||||||
|
if (!drag || e.pointerId !== drag.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
dragStateRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { cards, count, title, position, handlePointerDown, handlePointerMove, handlePointerUp };
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { Form, Field } from 'react-final-form';
|
import { Form, Field } from 'react-final-form';
|
||||||
|
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
|
|
@ -9,76 +7,26 @@ import StepLabel from '@mui/material/StepLabel';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
import { InputField, VirtualList } from '@app/components';
|
import { InputField, VirtualList } from '@app/components';
|
||||||
import { cardImporterService, CardDTO, SetDTO, TokenDTO } from '@app/services';
|
|
||||||
|
import { useCardImportForm } from './useCardImportForm';
|
||||||
|
|
||||||
import './CardImportForm.css';
|
import './CardImportForm.css';
|
||||||
|
|
||||||
const CardImportForm = ({ onSubmit: onClose }) => {
|
const CardImportForm = ({ onSubmit: onClose }) => {
|
||||||
|
const {
|
||||||
const [loading, setLoading] = useState(false);
|
loading,
|
||||||
const [activeStep, setActiveStep] = useState(0);
|
activeStep,
|
||||||
const [importedCards, setImportedCards] = useState([]);
|
importedCards,
|
||||||
const [importedSets, setImportedSets] = useState([]);
|
importedSets,
|
||||||
const [error, setError] = useState(null);
|
error,
|
||||||
|
handleBack,
|
||||||
useEffect(() => {
|
handleCardDownload,
|
||||||
if (loading) {
|
handleCardSave,
|
||||||
setError(null);
|
handleTokenDownload,
|
||||||
}
|
} = useCardImportForm();
|
||||||
}, [loading])
|
|
||||||
|
|
||||||
const steps = ['Imports sets', 'Save sets', 'Import tokens', 'Finished'];
|
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) => {
|
const getStepContent = (stepIndex) => {
|
||||||
switch (stepIndex) {
|
switch (stepIndex) {
|
||||||
case 0: return (
|
case 0: return (
|
||||||
|
|
|
||||||
93
webclient/src/forms/CardImportForm/useCardImportForm.ts
Normal file
93
webclient/src/forms/CardImportForm/useCardImportForm.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { cardImporterService, CardDTO, SetDTO, TokenDTO } from '@app/services';
|
||||||
|
|
||||||
|
export interface CardImportForm {
|
||||||
|
loading: boolean;
|
||||||
|
activeStep: number;
|
||||||
|
importedCards: any[];
|
||||||
|
importedSets: any[];
|
||||||
|
error: string | null;
|
||||||
|
handleNext: () => void;
|
||||||
|
handleBack: () => void;
|
||||||
|
handleCardDownload: (args: { cardDownloadUrl: string }) => void;
|
||||||
|
handleCardSave: () => Promise<void>;
|
||||||
|
handleTokenDownload: (args: { tokenDownloadUrl: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCardImportForm(): CardImportForm {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [activeStep, setActiveStep] = useState(0);
|
||||||
|
const [importedCards, setImportedCards] = useState<any[]>([]);
|
||||||
|
const [importedSets, setImportedSets] = useState<any[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [loading]);
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setActiveStep((prevActiveStep) => prevActiveStep - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardDownload = ({ cardDownloadUrl }: { cardDownloadUrl: string }) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
cardImporterService.importCards(cardDownloadUrl)
|
||||||
|
.then(({ cards, sets }) => {
|
||||||
|
setImportedCards(cards);
|
||||||
|
setImportedSets(sets);
|
||||||
|
|
||||||
|
handleNext();
|
||||||
|
})
|
||||||
|
.catch(({ message }) => setError(message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardSave = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await CardDTO.bulkAdd(importedCards);
|
||||||
|
await SetDTO.bulkAdd(importedSets);
|
||||||
|
|
||||||
|
handleNext();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('Failed to save cards');
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTokenDownload = ({ tokenDownloadUrl }: { tokenDownloadUrl: string }) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
cardImporterService.importTokens(tokenDownloadUrl)
|
||||||
|
.then(async (tokens) => {
|
||||||
|
await TokenDTO.bulkAdd(tokens);
|
||||||
|
handleNext();
|
||||||
|
})
|
||||||
|
.catch(({ message }) => setError(message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
activeStep,
|
||||||
|
importedCards,
|
||||||
|
importedSets,
|
||||||
|
error,
|
||||||
|
handleNext,
|
||||||
|
handleBack,
|
||||||
|
handleCardDownload,
|
||||||
|
handleCardSave,
|
||||||
|
handleTokenDownload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { Form, Field, useFormState, FormApi } from 'react-final-form';
|
import { Form, Field, FormApi } from 'react-final-form';
|
||||||
import { OnChange } from 'react-final-form-listeners';
|
import { OnChange } from 'react-final-form-listeners';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
|
@ -9,7 +9,8 @@ import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
|
||||||
import { CheckboxField, InputField, KnownHosts } from '@app/components';
|
import { CheckboxField, InputField, KnownHosts } from '@app/components';
|
||||||
import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
|
import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
|
||||||
import { HostDTO } from '@app/services';
|
|
||||||
|
import { useLoginFormBody } from './useLoginForm';
|
||||||
|
|
||||||
import './LoginForm.css';
|
import './LoginForm.css';
|
||||||
|
|
||||||
|
|
@ -34,61 +35,15 @@ const LoginFormBody = ({
|
||||||
const PASSWORD_LABEL = t('Common.label.password');
|
const PASSWORD_LABEL = t('Common.label.password');
|
||||||
const STORED_PASSWORD_LABEL = `* ${t('LoginForm.label.savedPassword')} *`;
|
const STORED_PASSWORD_LABEL = `* ${t('LoginForm.label.savedPassword')} *`;
|
||||||
|
|
||||||
const settings = useSettings();
|
const {
|
||||||
const hosts = useKnownHosts();
|
useStoredPasswordLabel,
|
||||||
const { values } = useFormState();
|
setUseStoredPasswordLabel,
|
||||||
|
onSelectedHostChange,
|
||||||
const selectedHost = hosts.status === LoadingState.READY ? hosts.value?.selectedHost : undefined;
|
onUserNameChange,
|
||||||
|
onRememberChange,
|
||||||
const [useStoredPasswordLabel, setUseStoredPasswordLabel] = useState(false);
|
onUserToggleAutoConnect,
|
||||||
const [storedHashInvalidated, setStoredHashInvalidated] = useState(false);
|
passwordFieldBlur,
|
||||||
|
} = useLoginFormBody(form);
|
||||||
const canUseStoredPassword = (remember: boolean, password: string | undefined) =>
|
|
||||||
Boolean(remember && selectedHost?.hashedPassword && !password && !storedHashInvalidated);
|
|
||||||
|
|
||||||
const togglePasswordLabel = (on: boolean) => setUseStoredPasswordLabel(on);
|
|
||||||
|
|
||||||
// @critical Host-sync must not touch autoConnect — app-level setting, not per-host.
|
|
||||||
const onSelectedHostChange = (host: HostDTO | undefined) => {
|
|
||||||
if (!host) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
form.change('userName', host.userName ?? '');
|
|
||||||
form.change('password', '');
|
|
||||||
form.change('remember', Boolean(host.remember));
|
|
||||||
setStoredHashInvalidated(false);
|
|
||||||
togglePasswordLabel(Boolean(host.remember && host.hashedPassword));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onUserNameChange = (userName: string | undefined) => {
|
|
||||||
const fieldChanged = selectedHost?.userName?.toLowerCase() !== userName?.toLowerCase();
|
|
||||||
if (canUseStoredPassword(values.remember, values.password) && fieldChanged) {
|
|
||||||
setStoredHashInvalidated(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRememberChange = (checked: boolean) => {
|
|
||||||
// @critical Writes form-only, never to persisted setting — "remember" toggle isn't a preference edit.
|
|
||||||
if (!checked && values.autoConnect) {
|
|
||||||
form.change('autoConnect', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
togglePasswordLabel(canUseStoredPassword(checked, values.password));
|
|
||||||
};
|
|
||||||
|
|
||||||
// @critical Only persist-path for autoConnect; wired to native onChange, not <OnChange>,
|
|
||||||
// to avoid leaking form.change() writes into Dexie.
|
|
||||||
const onUserToggleAutoConnect = (checked: boolean, fieldOnChange: (v: boolean) => void) => {
|
|
||||||
fieldOnChange(checked);
|
|
||||||
|
|
||||||
if (settings.status === LoadingState.READY) {
|
|
||||||
void settings.update({ autoConnect: checked });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked && !values.remember) {
|
|
||||||
form.change('remember', true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="loginForm" onSubmit={handleSubmit}>
|
<form className="loginForm" onSubmit={handleSubmit}>
|
||||||
|
|
@ -106,9 +61,7 @@ const LoginFormBody = ({
|
||||||
<Field
|
<Field
|
||||||
label={useStoredPasswordLabel ? STORED_PASSWORD_LABEL : PASSWORD_LABEL}
|
label={useStoredPasswordLabel ? STORED_PASSWORD_LABEL : PASSWORD_LABEL}
|
||||||
onFocus={() => setUseStoredPasswordLabel(false)}
|
onFocus={() => setUseStoredPasswordLabel(false)}
|
||||||
onBlur={() =>
|
onBlur={passwordFieldBlur}
|
||||||
togglePasswordLabel(canUseStoredPassword(values.remember, values.password))
|
|
||||||
}
|
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
component={InputField}
|
component={InputField}
|
||||||
|
|
|
||||||
92
webclient/src/forms/LoginForm/useLoginForm.ts
Normal file
92
webclient/src/forms/LoginForm/useLoginForm.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useFormState } from 'react-final-form';
|
||||||
|
|
||||||
|
import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
|
||||||
|
import { HostDTO } from '@app/services';
|
||||||
|
|
||||||
|
export interface LoginFormBody {
|
||||||
|
useStoredPasswordLabel: boolean;
|
||||||
|
setUseStoredPasswordLabel: (v: boolean) => void;
|
||||||
|
onSelectedHostChange: (host: HostDTO | undefined) => void;
|
||||||
|
onUserNameChange: (userName: string | undefined) => void;
|
||||||
|
onRememberChange: (checked: boolean) => void;
|
||||||
|
onUserToggleAutoConnect: (checked: boolean, fieldOnChange: (v: boolean) => void) => void;
|
||||||
|
passwordFieldBlur: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `FormApi` import from react-final-form is broken at the type level on this
|
||||||
|
// branch (baseline TS error). Only `form.change` is used here.
|
||||||
|
interface MinimalFormApi {
|
||||||
|
change: (name: string, value: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoginFormBody(form: MinimalFormApi): LoginFormBody {
|
||||||
|
const settings = useSettings();
|
||||||
|
const hosts = useKnownHosts();
|
||||||
|
const { values } = useFormState();
|
||||||
|
|
||||||
|
const selectedHost = hosts.status === LoadingState.READY ? hosts.value?.selectedHost : undefined;
|
||||||
|
|
||||||
|
const [useStoredPasswordLabel, setUseStoredPasswordLabel] = useState(false);
|
||||||
|
const [storedHashInvalidated, setStoredHashInvalidated] = useState(false);
|
||||||
|
|
||||||
|
const canUseStoredPassword = (remember: boolean, password: string | undefined) =>
|
||||||
|
Boolean(remember && selectedHost?.hashedPassword && !password && !storedHashInvalidated);
|
||||||
|
|
||||||
|
const togglePasswordLabel = (on: boolean) => setUseStoredPasswordLabel(on);
|
||||||
|
|
||||||
|
// @critical Host-sync must not touch autoConnect — app-level setting, not per-host.
|
||||||
|
const onSelectedHostChange = (host: HostDTO | undefined) => {
|
||||||
|
if (!host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.change('userName', host.userName ?? '');
|
||||||
|
form.change('password', '');
|
||||||
|
form.change('remember', Boolean(host.remember));
|
||||||
|
setStoredHashInvalidated(false);
|
||||||
|
togglePasswordLabel(Boolean(host.remember && host.hashedPassword));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUserNameChange = (userName: string | undefined) => {
|
||||||
|
const fieldChanged = selectedHost?.userName?.toLowerCase() !== userName?.toLowerCase();
|
||||||
|
if (canUseStoredPassword(values.remember, values.password) && fieldChanged) {
|
||||||
|
setStoredHashInvalidated(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRememberChange = (checked: boolean) => {
|
||||||
|
// @critical Writes form-only, never to persisted setting — "remember" toggle isn't a preference edit.
|
||||||
|
if (!checked && values.autoConnect) {
|
||||||
|
form.change('autoConnect', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePasswordLabel(canUseStoredPassword(checked, values.password));
|
||||||
|
};
|
||||||
|
|
||||||
|
// @critical Only persist-path for autoConnect; wired to native onChange, not <OnChange>,
|
||||||
|
// to avoid leaking form.change() writes into Dexie.
|
||||||
|
const onUserToggleAutoConnect = (checked: boolean, fieldOnChange: (v: boolean) => void) => {
|
||||||
|
fieldOnChange(checked);
|
||||||
|
|
||||||
|
if (settings.status === LoadingState.READY) {
|
||||||
|
void settings.update({ autoConnect: checked });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checked && !values.remember) {
|
||||||
|
form.change('remember', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordFieldBlur = () =>
|
||||||
|
togglePasswordLabel(canUseStoredPassword(values.remember, values.password));
|
||||||
|
|
||||||
|
return {
|
||||||
|
useStoredPasswordLabel,
|
||||||
|
setUseStoredPasswordLabel,
|
||||||
|
onSelectedHostChange,
|
||||||
|
onUserNameChange,
|
||||||
|
onRememberChange,
|
||||||
|
onUserToggleAutoConnect,
|
||||||
|
passwordFieldBlur,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { Form, Field } from 'react-final-form';
|
import { Form, Field } from 'react-final-form';
|
||||||
import { OnChange } from 'react-final-form-listeners';
|
import { OnChange } from 'react-final-form-listeners';
|
||||||
import setFieldTouched from 'final-form-set-field-touched';
|
import setFieldTouched from 'final-form-set-field-touched';
|
||||||
|
|
@ -9,45 +8,25 @@ import Button from '@mui/material/Button';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
import { CountryDropdown, InputField, KnownHosts } from '@app/components';
|
import { CountryDropdown, InputField, KnownHosts } from '@app/components';
|
||||||
import { useReduxEffect } from '@app/hooks';
|
import { ServerDispatch } from '@app/store';
|
||||||
import { ServerDispatch, ServerSelectors, ServerTypes } from '@app/store';
|
|
||||||
|
import { useRegisterForm } from './useRegisterForm';
|
||||||
|
|
||||||
import './RegisterForm.css';
|
import './RegisterForm.css';
|
||||||
import { useToast } from '@app/components';
|
|
||||||
|
|
||||||
const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
|
const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [emailRequired, setEmailRequired] = useState(false);
|
const {
|
||||||
const [emailError, setEmailError] = useState(null);
|
emailRequired,
|
||||||
const [passwordError, setPasswordError] = useState(null);
|
emailError,
|
||||||
const [userNameError, setUserNameError] = useState(null);
|
passwordError,
|
||||||
const error = useSelector(ServerSelectors.getRegistrationError);
|
userNameError,
|
||||||
const { openToast } = useToast({ key: 'registration-success', children: t('RegisterForm.toast.registerSuccess') })
|
error,
|
||||||
|
onHostChange,
|
||||||
const onHostChange = () => setEmailRequired(false);
|
onEmailChange,
|
||||||
const onEmailChange = () => emailError && setEmailError(null);
|
onPasswordChange,
|
||||||
const onPasswordChange = () => passwordError && setPasswordError(null);
|
onUserNameChange,
|
||||||
const onUserNameChange = () => userNameError && setUserNameError(null);
|
} = useRegisterForm();
|
||||||
|
|
||||||
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 handleOnSubmit = ({ userName, email, realName, ...values }) => {
|
const handleOnSubmit = ({ userName, email, realName, ...values }) => {
|
||||||
ServerDispatch.clearRegistrationErrors();
|
ServerDispatch.clearRegistrationErrors();
|
||||||
|
|
@ -57,7 +36,7 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
|
||||||
realName = realName?.trim();
|
realName = realName?.trim();
|
||||||
|
|
||||||
onSubmit({ userName, email, realName, ...values });
|
onSubmit({ userName, email, realName, ...values });
|
||||||
}
|
};
|
||||||
|
|
||||||
const validate = values => {
|
const validate = values => {
|
||||||
const errors: any = {};
|
const errors: any = {};
|
||||||
|
|
@ -93,7 +72,7 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleOnSubmit} validate={validate} mutators={{ setFieldTouched }}>
|
<Form onSubmit={handleOnSubmit} validate={validate} mutators={{ setFieldTouched }}>
|
||||||
|
|
|
||||||
69
webclient/src/forms/RegisterForm/useRegisterForm.ts
Normal file
69
webclient/src/forms/RegisterForm/useRegisterForm.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useToast } from '@app/components';
|
||||||
|
import { useReduxEffect } from '@app/hooks';
|
||||||
|
import { ServerSelectors, ServerTypes } from '@app/store';
|
||||||
|
|
||||||
|
export interface RegisterForm {
|
||||||
|
emailRequired: boolean;
|
||||||
|
emailError: string | null;
|
||||||
|
passwordError: string | null;
|
||||||
|
userNameError: string | null;
|
||||||
|
error: string | null;
|
||||||
|
onHostChange: () => void;
|
||||||
|
onEmailChange: () => void;
|
||||||
|
onPasswordChange: () => void;
|
||||||
|
onUserNameChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegisterForm(): RegisterForm {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [emailRequired, setEmailRequired] = useState(false);
|
||||||
|
const [emailError, setEmailError] = useState<string | null>(null);
|
||||||
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||||
|
const [userNameError, setUserNameError] = useState<string | null>(null);
|
||||||
|
const error = useSelector(ServerSelectors.getRegistrationError);
|
||||||
|
const { openToast } = useToast({
|
||||||
|
key: 'registration-success',
|
||||||
|
children: t('RegisterForm.toast.registerSuccess'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onHostChange = () => setEmailRequired(false);
|
||||||
|
const onEmailChange = () => emailError && setEmailError(null);
|
||||||
|
const onPasswordChange = () => passwordError && setPasswordError(null);
|
||||||
|
const onUserNameChange = () => userNameError && setUserNameError(null);
|
||||||
|
|
||||||
|
useReduxEffect(() => {
|
||||||
|
setEmailRequired(true);
|
||||||
|
}, ServerTypes.REGISTRATION_REQUIRES_EMAIL);
|
||||||
|
|
||||||
|
useReduxEffect(() => {
|
||||||
|
openToast();
|
||||||
|
}, ServerTypes.REGISTRATION_SUCCESS);
|
||||||
|
|
||||||
|
useReduxEffect(({ payload: { error } }) => {
|
||||||
|
setEmailError(error);
|
||||||
|
}, ServerTypes.REGISTRATION_EMAIL_ERROR);
|
||||||
|
|
||||||
|
useReduxEffect(({ payload: { error } }) => {
|
||||||
|
setPasswordError(error);
|
||||||
|
}, ServerTypes.REGISTRATION_PASSWORD_ERROR);
|
||||||
|
|
||||||
|
useReduxEffect(({ payload: { error } }) => {
|
||||||
|
setUserNameError(error);
|
||||||
|
}, ServerTypes.REGISTRATION_USERNAME_ERROR);
|
||||||
|
|
||||||
|
return {
|
||||||
|
emailRequired,
|
||||||
|
emailError,
|
||||||
|
passwordError,
|
||||||
|
userNameError,
|
||||||
|
error,
|
||||||
|
onHostChange,
|
||||||
|
onEmailChange,
|
||||||
|
onPasswordChange,
|
||||||
|
onUserNameChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
// eslint-disable-next-line
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { Form, Field } from 'react-final-form';
|
import { Form, Field } from 'react-final-form';
|
||||||
import { OnChange } from 'react-final-form-listeners';
|
import { OnChange } from 'react-final-form-listeners';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
@ -8,23 +6,14 @@ import Button from '@mui/material/Button';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
import { InputField, KnownHosts } from '@app/components';
|
import { InputField, KnownHosts } from '@app/components';
|
||||||
import { useReduxEffect } from '@app/hooks';
|
|
||||||
import { ServerTypes } from '@app/store';
|
import { useRequestPasswordResetForm } from './useRequestPasswordResetForm';
|
||||||
|
|
||||||
import './RequestPasswordResetForm.css';
|
import './RequestPasswordResetForm.css';
|
||||||
|
|
||||||
const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
|
const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
|
||||||
const [errorMessage, setErrorMessage] = useState(false);
|
|
||||||
const [isMFA, setIsMFA] = useState(false);
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { errorMessage, setErrorMessage, isMFA, setIsMFA } = useRequestPasswordResetForm();
|
||||||
useReduxEffect(() => {
|
|
||||||
setErrorMessage(true);
|
|
||||||
}, ServerTypes.RESET_PASSWORD_FAILED, []);
|
|
||||||
|
|
||||||
useReduxEffect(() => {
|
|
||||||
setIsMFA(true);
|
|
||||||
}, ServerTypes.RESET_PASSWORD_CHALLENGE, []);
|
|
||||||
|
|
||||||
const handleOnSubmit = ({ userName, email, ...values }) => {
|
const handleOnSubmit = ({ userName, email, ...values }) => {
|
||||||
setErrorMessage(false);
|
setErrorMessage(false);
|
||||||
|
|
@ -33,7 +22,7 @@ const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
|
||||||
email = email?.trim();
|
email = email?.trim();
|
||||||
|
|
||||||
onSubmit({ userName, email, ...values });
|
onSubmit({ userName, email, ...values });
|
||||||
}
|
};
|
||||||
|
|
||||||
const validate = values => {
|
const validate = values => {
|
||||||
const errors: any = {};
|
const errors: any = {};
|
||||||
|
|
@ -57,7 +46,7 @@ const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
|
||||||
const onHostChange: any = ({ userName }) => {
|
const onHostChange: any = ({ userName }) => {
|
||||||
form.change('userName', userName);
|
form.change('userName', userName);
|
||||||
setIsMFA(false);
|
setIsMFA(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="RequestPasswordResetForm" onSubmit={handleSubmit}>
|
<form className="RequestPasswordResetForm" onSubmit={handleSubmit}>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,18 @@
|
||||||
// eslint-disable-next-line
|
import { Form, Field } from 'react-final-form';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { Form, Field } from 'react-final-form'
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
import { InputField, KnownHosts } from '@app/components';
|
import { InputField, KnownHosts } from '@app/components';
|
||||||
import { useReduxEffect } from '@app/hooks';
|
|
||||||
import { ServerTypes } from '@app/store';
|
import { useResetPasswordForm } from './useResetPasswordForm';
|
||||||
|
|
||||||
import './ResetPasswordForm.css';
|
import './ResetPasswordForm.css';
|
||||||
|
|
||||||
const ResetPasswordForm = ({ onSubmit, userName }) => {
|
const ResetPasswordForm = ({ onSubmit, userName }) => {
|
||||||
const [errorMessage, setErrorMessage] = useState(false);
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { errorMessage } = useResetPasswordForm();
|
||||||
useReduxEffect(() => {
|
|
||||||
setErrorMessage(true);
|
|
||||||
}, ServerTypes.RESET_PASSWORD_FAILED, []);
|
|
||||||
|
|
||||||
const validate = values => {
|
const validate = values => {
|
||||||
const errors: any = {};
|
const errors: any = {};
|
||||||
|
|
@ -53,7 +47,7 @@ const ResetPasswordForm = ({ onSubmit, userName }) => {
|
||||||
token = token?.trim();
|
token = token?.trim();
|
||||||
|
|
||||||
onSubmit({ userName, token, ...values });
|
onSubmit({ userName, token, ...values });
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleOnSubmit} validate={validate} initialValues={{ userName }}>
|
<Form onSubmit={handleOnSubmit} validate={validate} initialValues={{ userName }}>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue