mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-04-27 07:48:01 -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 Card from '../Card/Card';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { useMemo } from 'react';
|
||||
import { App, Data } from '@app/types';
|
||||
import { GameSelectors, useAppSelector } from '@app/store';
|
||||
import { useSettings } from '@app/hooks';
|
||||
|
||||
import CardSlot from '../CardSlot/CardSlot';
|
||||
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
|
||||
import BattlefieldRow from './BattlefieldRow';
|
||||
import { useBattlefield } from './useBattlefield';
|
||||
|
||||
import './Battlefield.css';
|
||||
|
||||
|
|
@ -21,13 +19,6 @@ export interface BattlefieldProps {
|
|||
onCardDoubleClick?: (card: Data.ServerInfo_Card) => void;
|
||||
}
|
||||
|
||||
const ROW_COUNT = 3;
|
||||
|
||||
function rowIndexFor(card: Data.ServerInfo_Card): number {
|
||||
const y = card.y ?? 0;
|
||||
return Math.max(0, Math.min(ROW_COUNT - 1, y));
|
||||
}
|
||||
|
||||
function Battlefield({
|
||||
gameId,
|
||||
playerId,
|
||||
|
|
@ -39,28 +30,7 @@ function Battlefield({
|
|||
onCardContextMenu,
|
||||
onCardDoubleClick,
|
||||
}: BattlefieldProps) {
|
||||
const cards = useAppSelector((state) =>
|
||||
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.TABLE),
|
||||
);
|
||||
|
||||
const { value: settings } = useSettings();
|
||||
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
|
||||
// Mirrors desktop TableZone::isInverted() — XOR of per-player mirrored and
|
||||
// the global invertVerticalCoordinate preference.
|
||||
const isInverted = mirrored !== invertVerticalCoordinate;
|
||||
|
||||
const rows = useMemo<Data.ServerInfo_Card[][]>(() => {
|
||||
const bucketed: Data.ServerInfo_Card[][] = Array.from({ length: ROW_COUNT }, () => []);
|
||||
for (const card of cards) {
|
||||
bucketed[rowIndexFor(card)].push(card);
|
||||
}
|
||||
for (const row of bucketed) {
|
||||
row.sort((a, b) => (a.x ?? 0) - (b.x ?? 0));
|
||||
}
|
||||
return bucketed;
|
||||
}, [cards]);
|
||||
|
||||
const rowOrder = isInverted ? [2, 1, 0] : [0, 1, 2];
|
||||
const { rows, rowOrder, isInverted } = useBattlefield({ gameId, playerId, mirrored });
|
||||
|
||||
return (
|
||||
<div className="battlefield" data-testid="battlefield">
|
||||
|
|
|
|||
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 Divider from '@mui/material/Divider';
|
||||
|
||||
import { useWebClient } from '@app/hooks';
|
||||
import { App, Data } from '@app/types';
|
||||
import { Data } from '@app/types';
|
||||
|
||||
import { useCardContextMenu } from './useCardContextMenu';
|
||||
|
||||
import './CardContextMenu.css';
|
||||
|
||||
|
|
@ -24,156 +25,33 @@ export interface CardContextMenuProps {
|
|||
onRequestMoveToLibraryAt: () => void;
|
||||
}
|
||||
|
||||
interface MoveTarget {
|
||||
label: string;
|
||||
zone: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
function CardContextMenu(props: CardContextMenuProps) {
|
||||
const { isOpen, anchorPosition, card, onClose } = props;
|
||||
const {
|
||||
ready,
|
||||
isOwnedByLocal,
|
||||
canAttach,
|
||||
isAttached,
|
||||
moveTargets,
|
||||
handleFlip,
|
||||
handleTapToggle,
|
||||
handleFaceDownToggle,
|
||||
handleDoesntUntapToggle,
|
||||
handleSetPT,
|
||||
handleSetAnnotation,
|
||||
handleCardCounterDelta,
|
||||
handleSetCardCounter,
|
||||
handleDrawArrow,
|
||||
handleAttach,
|
||||
handleUnattach,
|
||||
handleMove,
|
||||
handleMoveToLibraryAt,
|
||||
} = useCardContextMenu(props);
|
||||
|
||||
// Mirrors desktop's cockatrice/src/game/player/menu/move_menu.cpp:32-42 —
|
||||
// six fixed targets plus one prompt ("Move to library at position…") for the
|
||||
// 7-entry parity. Note that desktop's "Send to Table" label maps to our
|
||||
// "Send to Battlefield" (same wire semantics: zone=table, x=0, y=0); the
|
||||
// label diverges but the command is identical.
|
||||
const MOVE_TARGETS: ReadonlyArray<MoveTarget> = [
|
||||
{ label: 'Send to Hand', zone: App.ZoneName.HAND, x: -1, y: 0 },
|
||||
{ label: 'Send to Battlefield', zone: App.ZoneName.TABLE, x: 0, y: 0 },
|
||||
{ label: 'Send to Graveyard', zone: App.ZoneName.GRAVE, x: 0, y: 0 },
|
||||
{ label: 'Send to Exile', zone: App.ZoneName.EXILE, x: 0, y: 0 },
|
||||
{ label: 'Send to Library (top)', zone: App.ZoneName.DECK, x: 0, y: 0 },
|
||||
{ label: 'Send to Library (bottom)', zone: App.ZoneName.DECK, x: -1, y: 0 },
|
||||
];
|
||||
|
||||
function CardContextMenu({
|
||||
isOpen,
|
||||
anchorPosition,
|
||||
gameId,
|
||||
localPlayerId,
|
||||
card,
|
||||
ownerPlayerId,
|
||||
sourceZone,
|
||||
onClose,
|
||||
onRequestSetPT,
|
||||
onRequestSetAnnotation,
|
||||
onRequestSetCounter,
|
||||
onRequestDrawArrow,
|
||||
onRequestAttach,
|
||||
onRequestMoveToLibraryAt,
|
||||
}: CardContextMenuProps) {
|
||||
const webClient = useWebClient();
|
||||
|
||||
if (!card || ownerPlayerId == null || sourceZone == null || localPlayerId == null) {
|
||||
if (!ready || !card) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const game = webClient.request.game;
|
||||
const zone = sourceZone;
|
||||
const cardId = card.id;
|
||||
|
||||
const setAttr = (attribute: Data.CardAttribute, value: string) => {
|
||||
game.setCardAttr(gameId, { zone, cardId, attribute, attrValue: value });
|
||||
};
|
||||
|
||||
const handleFlip = () => {
|
||||
// TODO(card-db): desktop's Player::actCardMenuFlip reads the card's stored
|
||||
// P/T and forwards it so the revealed side shows the correct stats
|
||||
// (cockatrice/src/game/player/player_actions.cpp:1805-1810). We can't
|
||||
// do that without a card-database-by-name lookup, which isn't wired in
|
||||
// the webclient yet. The server re-derives PT from the card DB for known
|
||||
// names, so omitting `pt` is harmless for non-custom cards.
|
||||
game.flipCard(gameId, { zone, cardId, faceDown: !card.faceDown });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleTapToggle = () => {
|
||||
setAttr(Data.CardAttribute.AttrTapped, card.tapped ? '0' : '1');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleFaceDownToggle = () => {
|
||||
setAttr(Data.CardAttribute.AttrFaceDown, card.faceDown ? '0' : '1');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDoesntUntapToggle = () => {
|
||||
setAttr(Data.CardAttribute.AttrDoesntUntap, card.doesntUntap ? '0' : '1');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSetPT = () => {
|
||||
onRequestSetPT();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSetAnnotation = () => {
|
||||
onRequestSetAnnotation();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCardCounterDelta = (delta: number) => {
|
||||
game.incCardCounter(gameId, {
|
||||
zone,
|
||||
cardId,
|
||||
counterId: 0,
|
||||
counterDelta: delta,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSetCardCounter = () => {
|
||||
onRequestSetCounter();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDrawArrow = () => {
|
||||
onRequestDrawArrow();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleAttach = () => {
|
||||
onRequestAttach();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleUnattach = () => {
|
||||
// Desktop's actUnattach sends only start_zone + card_id; the server uses
|
||||
// proto2 presence (`has_target_player_id()`) to detect "detach". Setting
|
||||
// targetPlayerId: -1 here would leave presence set and trip the attach
|
||||
// code path server-side. MessageInitShape makes these fields optional,
|
||||
// so omitting them produces an unset wire field.
|
||||
game.attachCard(gameId, { startZone: zone, cardId });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isAttached = card.attachCardId >= 0;
|
||||
// Desktop's actAttach is only available from a table card; other zones
|
||||
// never expose the attach arrow.
|
||||
const canAttach = sourceZone === App.ZoneName.TABLE;
|
||||
|
||||
// Mutating actions (tap, flip, counters, attrs, P/T, annotation, attach,
|
||||
// move) require ownership of the card — matches desktop's
|
||||
// `card_menu.cpp:151-161` which drops all mutators when the menu target
|
||||
// isn't getLocalOrJudge()-modifiable. Read-only actions (Draw arrow)
|
||||
// stay available for planning/communication.
|
||||
const isOwnedByLocal = ownerPlayerId === localPlayerId;
|
||||
|
||||
const handleMove = (target: MoveTarget) => {
|
||||
// targetPlayerId is the ACTING player (local), matching desktop's
|
||||
// Player::actMoveCardTo* which uses playerInfo->getId().
|
||||
game.moveCard(gameId, {
|
||||
startPlayerId: ownerPlayerId,
|
||||
startZone: sourceZone,
|
||||
cardsToMove: { card: [{ cardId }] },
|
||||
targetPlayerId: localPlayerId,
|
||||
targetZone: target.zone,
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
isReversed: false,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={isOpen}
|
||||
|
|
@ -212,17 +90,12 @@ function CardContextMenu({
|
|||
{isOwnedByLocal && (
|
||||
<>
|
||||
<Divider />
|
||||
{MOVE_TARGETS.map((t) => (
|
||||
{moveTargets.map((t) => (
|
||||
<MenuItem key={t.label} onClick={() => handleMove(t)}>
|
||||
{t.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
onRequestMoveToLibraryAt();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={handleMoveToLibraryAt}>
|
||||
Move to library at position…
|
||||
</MenuItem>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 { cx } from '@app/utils';
|
||||
|
||||
import { makeCardKey, useRegisterCardRef } from '../CardRegistry/CardRegistryContext';
|
||||
import { useCardSlot } from './useCardSlot';
|
||||
|
||||
import './CardSlot.css';
|
||||
|
||||
|
|
@ -38,55 +33,13 @@ function CardSlot({
|
|||
onContextMenu,
|
||||
onMouseEnter,
|
||||
}: CardSlotProps) {
|
||||
const { smallUrl } = useScryfallCard(card);
|
||||
|
||||
// React-stable id salts the dnd-kit IDs so even two disabled CardSlots
|
||||
// rendering the same card (during state transitions / hidden-zone leaks)
|
||||
// never collide. Without the salt, pre-owner/zone render cycles shared
|
||||
// `card-x-x-<id>` and dnd-kit warned.
|
||||
const instanceId = useId();
|
||||
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `card-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
|
||||
data: { card, sourcePlayerId: ownerPlayerId, sourceZone: zone },
|
||||
disabled: !draggable || ownerPlayerId == null || zone == null,
|
||||
const { smallUrl, attributes, listeners, isDragging, isOver, rootRef } = useCardSlot({
|
||||
card,
|
||||
draggable,
|
||||
ownerPlayerId,
|
||||
zone,
|
||||
});
|
||||
|
||||
// Cards on the battlefield double as drop targets for drag-to-attach.
|
||||
// Other zones don't support attach (desktop's Player::actAttach rejects
|
||||
// non-table targets), so the droppable is only live for TABLE.
|
||||
const droppableEnabled =
|
||||
ownerPlayerId != null && zone === App.ZoneName.TABLE;
|
||||
const { setNodeRef: setDropRef, isOver } = useDroppable({
|
||||
id: `card-drop-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
|
||||
data: {
|
||||
attachTarget: true,
|
||||
targetPlayerId: ownerPlayerId,
|
||||
targetZone: zone,
|
||||
targetCardId: card.id,
|
||||
},
|
||||
disabled: !droppableEnabled,
|
||||
});
|
||||
|
||||
const registryKey =
|
||||
ownerPlayerId != null && zone != null
|
||||
? makeCardKey(ownerPlayerId, zone, card.id)
|
||||
: null;
|
||||
const registerRef = useRegisterCardRef(registryKey);
|
||||
|
||||
const rootRef = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
registerRef(el);
|
||||
if (draggable) {
|
||||
setNodeRef(el);
|
||||
}
|
||||
if (droppableEnabled) {
|
||||
setDropRef(el);
|
||||
}
|
||||
},
|
||||
[registerRef, setNodeRef, setDropRef, draggable, droppableEnabled],
|
||||
);
|
||||
|
||||
const className = cx('card-slot', {
|
||||
'card-slot--tapped': card.tapped,
|
||||
'card-slot--inverted': inverted,
|
||||
|
|
|
|||
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 { useWebClient } from '@app/hooks';
|
||||
import { GameSelectors, useAppSelector } from '@app/store';
|
||||
import { App } from '@app/types';
|
||||
import type { Data, Enriched } from '@app/types';
|
||||
|
||||
import { makeCardKey, useCardRegistry } from '../CardRegistry/CardRegistryContext';
|
||||
import { useGameArrowOverlay } from './useGameArrowOverlay';
|
||||
|
||||
import './GameArrowOverlay.css';
|
||||
|
||||
|
|
@ -15,108 +8,8 @@ export interface GameArrowOverlayProps {
|
|||
dragPreview?: { x1: number; y1: number; x2: number; y2: number; color: string } | null;
|
||||
}
|
||||
|
||||
interface ResolvedArrow {
|
||||
arrowId: number;
|
||||
ownerPlayerId: number;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const ARROW_FALLBACK_CSS = App.rgbaToCss(App.ArrowColor.RED);
|
||||
|
||||
function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string {
|
||||
if (!c) {
|
||||
return ARROW_FALLBACK_CSS;
|
||||
}
|
||||
return App.rgbaToCss({ r: c.r, g: c.g, b: c.b, a: c.a ?? 255 });
|
||||
}
|
||||
|
||||
function GameArrowOverlay({ gameId, boardRef, dragPreview = null }: GameArrowOverlayProps) {
|
||||
const webClient = useWebClient();
|
||||
const registry = useCardRegistry();
|
||||
const players = useAppSelector((state) =>
|
||||
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
|
||||
);
|
||||
|
||||
// Tick is bumped whenever we need to re-query DOM rects (card registry
|
||||
// mutation, board resize). Keeps the overlay declarative without an external
|
||||
// layout engine.
|
||||
const [tick, setTick] = useState(0);
|
||||
const bump = useCallback(() => {
|
||||
setTick((t) => t + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!registry) {
|
||||
return undefined;
|
||||
}
|
||||
return registry.subscribe(bump);
|
||||
}, [registry, bump]);
|
||||
|
||||
// First-paint: the board ref is null during the initial render, so `boardRect`
|
||||
// is undefined and the arrows memo bails out. Bump once after mount so the
|
||||
// next render sees a populated ref.
|
||||
useLayoutEffect(() => {
|
||||
bump();
|
||||
}, [bump]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = boardRef.current;
|
||||
if (!el || typeof ResizeObserver === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
const ro = new ResizeObserver(() => bump());
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [boardRef, bump]);
|
||||
|
||||
const boardRect = boardRef.current?.getBoundingClientRect();
|
||||
|
||||
const arrows = useMemo<ResolvedArrow[]>(() => {
|
||||
if (!players || !registry || !boardRect) {
|
||||
return [];
|
||||
}
|
||||
const out: ResolvedArrow[] = [];
|
||||
for (const player of Object.values(players) as Enriched.PlayerEntry[]) {
|
||||
for (const a of Object.values(player.arrows) as Data.ServerInfo_Arrow[]) {
|
||||
const sourceEl = registry.get(
|
||||
makeCardKey(a.startPlayerId, a.startZone, a.startCardId),
|
||||
);
|
||||
const targetEl = registry.get(
|
||||
makeCardKey(a.targetPlayerId, a.targetZone, a.targetCardId),
|
||||
);
|
||||
if (!sourceEl || !targetEl) {
|
||||
continue;
|
||||
}
|
||||
const s = sourceEl.getBoundingClientRect();
|
||||
const t = targetEl.getBoundingClientRect();
|
||||
out.push({
|
||||
arrowId: a.id,
|
||||
ownerPlayerId: player.properties.playerId,
|
||||
x1: s.left + s.width / 2 - boardRect.left,
|
||||
y1: s.top + s.height / 2 - boardRect.top,
|
||||
x2: t.left + t.width / 2 - boardRect.left,
|
||||
y2: t.top + t.height / 2 - boardRect.top,
|
||||
color: cssColor(a.arrowColor),
|
||||
});
|
||||
}
|
||||
}
|
||||
// `tick` in deps intentionally re-runs the memo on DOM-layout changes.
|
||||
return out;
|
||||
}, [players, registry, boardRect, tick]);
|
||||
|
||||
const handleArrowClick = (arrowId: number) => {
|
||||
if (gameId == null) {
|
||||
return;
|
||||
}
|
||||
webClient.request.game.deleteArrow(gameId, { arrowId });
|
||||
};
|
||||
|
||||
const width = boardRect?.width ?? 0;
|
||||
const height = boardRect?.height ?? 0;
|
||||
const { arrows, width, height, handleArrowClick } = useGameArrowOverlay({ gameId, boardRef });
|
||||
|
||||
return (
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -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 { useWebClient } from '@app/hooks';
|
||||
import { GameSelectors, useAppSelector } from '@app/store';
|
||||
import type { Enriched } from '@app/types';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { formatElapsed, useGameLog } from './useGameLog';
|
||||
|
||||
import './GameLog.css';
|
||||
|
||||
const EMPTY_MESSAGES: Enriched.GameMessage[] = [];
|
||||
|
||||
function formatElapsed(totalSeconds: number): string {
|
||||
const s = Math.max(0, Math.floor(totalSeconds));
|
||||
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
|
||||
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
|
||||
const ss = String(s % 60).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
export interface GameLogProps {
|
||||
gameId: number | undefined;
|
||||
}
|
||||
|
||||
function GameLog({ gameId }: GameLogProps) {
|
||||
const webClient = useWebClient();
|
||||
const messages = useAppSelector((state) =>
|
||||
gameId != null ? GameSelectors.getMessages(state, gameId) : EMPTY_MESSAGES,
|
||||
);
|
||||
const players = useAppSelector((state) =>
|
||||
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
|
||||
);
|
||||
const secondsElapsed = useAppSelector((state) =>
|
||||
gameId != null ? GameSelectors.getSecondsElapsed(state, gameId) : 0,
|
||||
);
|
||||
|
||||
// Local 1Hz ticker, resynced from Redux whenever a server event delivers a
|
||||
// fresh `secondsElapsed`. Mirrors desktop's QTimer(1000) +
|
||||
// setGameTime(event.seconds_elapsed()) pattern in game_state.cpp.
|
||||
const [displaySeconds, setDisplaySeconds] = useState(secondsElapsed);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplaySeconds(secondsElapsed);
|
||||
}, [secondsElapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameId == null) {
|
||||
return undefined;
|
||||
}
|
||||
const id = window.setInterval(() => {
|
||||
setDisplaySeconds((prev) => prev + 1);
|
||||
}, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [gameId]);
|
||||
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
// Desktop pins the log to the bottom unless the user has scrolled up to read backlog.
|
||||
// Capture pin state before the new line renders so auto-scroll only fires when the
|
||||
// user was already following the tail.
|
||||
const wasPinnedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
if (wasPinnedRef.current) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
const handleMessagesScroll = () => {
|
||||
const el = listRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
wasPinnedRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 2;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (gameId == null) {
|
||||
return;
|
||||
}
|
||||
const trimmed = draft.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return;
|
||||
}
|
||||
webClient.request.game.gameSay(gameId, { message: trimmed });
|
||||
setDraft('');
|
||||
};
|
||||
const {
|
||||
messages,
|
||||
players,
|
||||
displaySeconds,
|
||||
draft,
|
||||
setDraft,
|
||||
handleMessagesScroll,
|
||||
handleSubmit,
|
||||
} = useGameLog({ gameId, listRef });
|
||||
|
||||
return (
|
||||
<div className="game-log" data-testid="game-log">
|
||||
|
|
|
|||
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 Divider from '@mui/material/Divider';
|
||||
|
||||
import { useWebClient } from '@app/hooks';
|
||||
import { useHandContextMenu } from './useHandContextMenu';
|
||||
|
||||
import './HandContextMenu.css';
|
||||
|
||||
|
|
@ -27,51 +27,15 @@ function HandContextMenu({
|
|||
onRequestRevealHand,
|
||||
onRequestRevealRandom,
|
||||
}: HandContextMenuProps) {
|
||||
const webClient = useWebClient();
|
||||
|
||||
const handleChoose = () => {
|
||||
if (gameId <= 0) {
|
||||
return;
|
||||
}
|
||||
onRequestChooseMulligan();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSameSize = () => {
|
||||
if (gameId <= 0) {
|
||||
return;
|
||||
}
|
||||
webClient.request.game.mulligan(gameId, { number: handSize });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleMinusOne = () => {
|
||||
if (gameId <= 0) {
|
||||
return;
|
||||
}
|
||||
// Desktop's actMulliganMinusOne floors at 1 (see
|
||||
// cockatrice/src/game/player/player_actions.cpp actMulliganMinusOne);
|
||||
// the server-side doMulligan rejects number < 1.
|
||||
const next = Math.max(1, handSize - 1);
|
||||
webClient.request.game.mulligan(gameId, { number: next });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRevealHand = () => {
|
||||
if (gameId <= 0) {
|
||||
return;
|
||||
}
|
||||
onRequestRevealHand();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRevealRandom = () => {
|
||||
if (gameId <= 0) {
|
||||
return;
|
||||
}
|
||||
onRequestRevealRandom();
|
||||
onClose();
|
||||
};
|
||||
const { handleChoose, handleSameSize, handleMinusOne, handleRevealHand, handleRevealRandom } =
|
||||
useHandContextMenu({
|
||||
gameId,
|
||||
handSize,
|
||||
onClose,
|
||||
onRequestChooseMulligan,
|
||||
onRequestRevealHand,
|
||||
onRequestRevealRandom,
|
||||
});
|
||||
|
||||
return (
|
||||
<Menu
|
||||
|
|
|
|||
|
|
@ -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 { GameSelectors, useAppSelector } from '@app/store';
|
||||
import { cx } from '@app/utils';
|
||||
|
||||
import CardSlot from '../CardSlot/CardSlot';
|
||||
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
|
||||
import { useHandZone } from './useHandZone';
|
||||
|
||||
import './HandZone.css';
|
||||
|
||||
|
|
@ -29,33 +28,13 @@ function HandZone({
|
|||
onCardContextMenu,
|
||||
onZoneContextMenu,
|
||||
}: HandZoneProps) {
|
||||
const cards = useAppSelector((state) =>
|
||||
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.HAND),
|
||||
);
|
||||
|
||||
// Match desktop: can't drop into a hand zone that isn't yours (judges
|
||||
// aside; server enforces the same restriction). Today only the local
|
||||
// HandZone mounts, but this guard future-proofs opponent-hand mirrors.
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `hand-${playerId}`,
|
||||
data: { targetPlayerId: playerId, targetZone: App.ZoneName.HAND },
|
||||
disabled: !canAct,
|
||||
const { cards, setNodeRef, isOver, handleZoneContextMenu } = useHandZone({
|
||||
gameId,
|
||||
playerId,
|
||||
canAct,
|
||||
onZoneContextMenu,
|
||||
});
|
||||
|
||||
// Right-click anywhere inside the hand that doesn't land on a card opens
|
||||
// the hand zone context menu (mulligan / reveal hand). Card-level right-
|
||||
// click has its own handler on CardSlot.
|
||||
const handleZoneContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!onZoneContextMenu) {
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-card-id]')) {
|
||||
return;
|
||||
}
|
||||
onZoneContextMenu(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
|
|
|
|||
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 { useCurrentGame, useWebClient } from '@app/hooks';
|
||||
import { GameSelectors, useAppSelector } from '@app/store';
|
||||
import { App, Data } from '@app/types';
|
||||
import { App } from '@app/types';
|
||||
import { cx } from '@app/utils';
|
||||
|
||||
import { usePhaseBar } from './usePhaseBar';
|
||||
|
||||
import './PhaseBar.css';
|
||||
|
||||
export interface PhaseBarProps {
|
||||
|
|
@ -35,65 +35,8 @@ const PHASE_LABELS: ReadonlyArray<{
|
|||
];
|
||||
|
||||
function PhaseBar({ gameId }: PhaseBarProps) {
|
||||
const webClient = useWebClient();
|
||||
const { game, isJudge, isStarted } = useCurrentGame(gameId);
|
||||
const activePhase = useAppSelector((state) =>
|
||||
gameId != null ? GameSelectors.getActivePhase(state, gameId) : undefined,
|
||||
);
|
||||
const localPlayerId = game?.localPlayerId;
|
||||
const tableCards = useAppSelector((state) =>
|
||||
gameId != null && localPlayerId != null
|
||||
? GameSelectors.getCards(state, gameId, localPlayerId, App.ZoneName.TABLE)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Desktop: only the active player (or a judge) can advance the phase.
|
||||
const canAdvance =
|
||||
gameId != null &&
|
||||
game != null &&
|
||||
isStarted &&
|
||||
(isJudge || game.activePlayerId === game.localPlayerId);
|
||||
|
||||
const handlePhaseClick = (phase: App.Phase) => {
|
||||
if (!canAdvance || gameId == null) {
|
||||
return;
|
||||
}
|
||||
webClient.request.game.setActivePhase(gameId, { phase });
|
||||
};
|
||||
|
||||
const handlePass = () => {
|
||||
if (!canAdvance || gameId == null) {
|
||||
return;
|
||||
}
|
||||
webClient.request.game.nextTurn(gameId);
|
||||
};
|
||||
|
||||
// Desktop's untap-step double-click fires "Untap All" on the local player's
|
||||
// table zone (cockatrice/src/game/player/player_actions.cpp actUntapAll).
|
||||
// We replicate by sending one setCardAttr per tapped card; there is no
|
||||
// batch variant on the wire.
|
||||
const handleUntapAll = () => {
|
||||
if (!canAdvance || gameId == null || !tableCards) {
|
||||
return;
|
||||
}
|
||||
for (const card of tableCards) {
|
||||
if (card.tapped) {
|
||||
webClient.request.game.setCardAttr(gameId, {
|
||||
zone: App.ZoneName.TABLE,
|
||||
cardId: card.id,
|
||||
attribute: Data.CardAttribute.AttrTapped,
|
||||
attrValue: '0',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrawOne = () => {
|
||||
if (!canAdvance || gameId == null) {
|
||||
return;
|
||||
}
|
||||
webClient.request.game.drawCards(gameId, { number: 1 });
|
||||
};
|
||||
const { activePhase, canAdvance, handlePhaseClick, handlePass, handleUntapAll, handleDrawOne } =
|
||||
usePhaseBar(gameId);
|
||||
|
||||
const onDoubleClickFor = (kind: 'untapAll' | 'drawCard' | undefined) => {
|
||||
if (kind === 'untapAll') {
|
||||
|
|
|
|||
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 type { Data } from '@app/types';
|
||||
|
||||
import { cssColor, usePlayerInfoPanel } from './usePlayerInfoPanel';
|
||||
|
||||
import './PlayerInfoPanel.css';
|
||||
|
||||
export interface PlayerInfoPanelProps {
|
||||
|
|
@ -14,21 +13,6 @@ export interface PlayerInfoPanelProps {
|
|||
onContextMenu?: (event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string {
|
||||
if (!c) {
|
||||
return '#666';
|
||||
}
|
||||
return `rgba(${c.r}, ${c.g}, ${c.b}, ${(c.a ?? 255) / 255})`;
|
||||
}
|
||||
|
||||
// Desktop renders Life larger/bolder than other counters (see
|
||||
// cockatrice/src/game/player/player.cpp PlayerTarget sizing). We special-
|
||||
// case the counter whose name is exactly 'Life' (case-insensitive) and
|
||||
// pull it out of the regular counter list into a prominent life block.
|
||||
function isLifeCounter(c: { name: string }): boolean {
|
||||
return c.name.trim().toLowerCase() === 'life';
|
||||
}
|
||||
|
||||
function PlayerInfoPanel({
|
||||
gameId,
|
||||
playerId,
|
||||
|
|
@ -36,13 +20,20 @@ function PlayerInfoPanel({
|
|||
onRequestCreateCounter,
|
||||
onContextMenu,
|
||||
}: PlayerInfoPanelProps) {
|
||||
const webClient = useWebClient();
|
||||
const player = useAppSelector((state) => GameSelectors.getPlayer(state, gameId, playerId));
|
||||
const counters = useAppSelector((state) => GameSelectors.getCounters(state, gameId, playerId));
|
||||
const hostId = useAppSelector((state) => GameSelectors.getHostId(state, gameId));
|
||||
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editDraft, setEditDraft] = useState('');
|
||||
const {
|
||||
player,
|
||||
isHost,
|
||||
lifeCounter,
|
||||
otherCounters,
|
||||
editingId,
|
||||
editDraft,
|
||||
setEditDraft,
|
||||
beginEdit,
|
||||
commitEdit,
|
||||
cancelEdit,
|
||||
handleIncrement,
|
||||
handleDelete,
|
||||
} = usePlayerInfoPanel({ gameId, playerId });
|
||||
|
||||
if (!player) {
|
||||
return <div className="player-info-panel player-info-panel--empty" />;
|
||||
|
|
@ -53,44 +44,6 @@ function PlayerInfoPanel({
|
|||
const conceded = player.properties.conceded;
|
||||
const ready = player.properties.readyStart;
|
||||
const sideboardLocked = player.properties.sideboardLocked ?? false;
|
||||
const isHost = hostId != null && hostId === playerId;
|
||||
const allCounters = Object.values(counters);
|
||||
const lifeCounter = allCounters.find(isLifeCounter);
|
||||
const otherCounters = allCounters.filter((c) => !isLifeCounter(c));
|
||||
|
||||
const handleIncrement = (counterId: number, delta: number) => {
|
||||
webClient.request.game.incCounter(gameId, { counterId, delta });
|
||||
};
|
||||
|
||||
const handleDelete = (counterId: number) => {
|
||||
webClient.request.game.delCounter(gameId, { counterId });
|
||||
};
|
||||
|
||||
const beginEdit = (counterId: number, currentValue: number) => {
|
||||
setEditingId(counterId);
|
||||
setEditDraft(String(currentValue));
|
||||
};
|
||||
|
||||
const commitEdit = (counterId: number) => {
|
||||
const trimmed = editDraft.trim();
|
||||
// Empty input cancels the edit (desktop inline edits treat blur-with-
|
||||
// no-change and blur-with-empty-string identically). Prior behavior
|
||||
// coerced '' → 0 because `Number('')` is 0 and `Number.isInteger(0)` is
|
||||
// true, which surprised users expecting cancel-on-blank.
|
||||
if (trimmed.length === 0) {
|
||||
setEditingId(null);
|
||||
return;
|
||||
}
|
||||
const value = Number(trimmed);
|
||||
if (Number.isInteger(value)) {
|
||||
webClient.request.game.setCounter(gameId, { counterId, value });
|
||||
}
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const renderCounterRow = (c: Data.ServerInfo_Counter) => (
|
||||
<li
|
||||
|
|
|
|||
|
|
@ -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 MenuItem from '@mui/material/MenuItem';
|
||||
|
||||
import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks';
|
||||
import { GameSelectors, useAppSelector } from '@app/store';
|
||||
import { useTurnControls } from './useTurnControls';
|
||||
|
||||
import './TurnControls.css';
|
||||
|
||||
|
|
@ -26,129 +24,31 @@ function TurnControls({
|
|||
onToggleRotate90,
|
||||
isRotated,
|
||||
}: TurnControlsProps) {
|
||||
const webClient = useWebClient();
|
||||
const { game, localPlayer, isSpectator, isJudge, isHost, isStarted } = useCurrentGame(gameId);
|
||||
const { status: settingsStatus, value: settings, update: updateSettings } = useSettings();
|
||||
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
|
||||
|
||||
// Post-kick: the reducer has deleted the game from state but the dialog
|
||||
// may still be mounted for a frame while `useGameLifecycle` navigates to
|
||||
// /server. Every handler double-checks `game` so a trailing click can't
|
||||
// fire a command against a game the server no longer has.
|
||||
const hasLiveGame = gameId != null && game != null;
|
||||
|
||||
const [kickAnchor, setKickAnchor] = useState<HTMLElement | null>(null);
|
||||
|
||||
const opponents = useMemo(() => {
|
||||
if (!game) {
|
||||
return [];
|
||||
}
|
||||
return Object.values(game.players)
|
||||
.filter((p) => p.properties.playerId !== game.localPlayerId)
|
||||
.map((p) => ({
|
||||
playerId: p.properties.playerId,
|
||||
name: p.properties.userInfo?.name ?? `p${p.properties.playerId}`,
|
||||
}));
|
||||
}, [game]);
|
||||
|
||||
// Local arrows belong to `localPlayerId`; Remove Local Arrows iterates
|
||||
// and deletes each one. Matches desktop's Player::actRemoveLocalArrows.
|
||||
const localArrows = useAppSelector((state) =>
|
||||
gameId != null && game != null
|
||||
? GameSelectors.getArrows(state, gameId, game.localPlayerId)
|
||||
: undefined,
|
||||
);
|
||||
const localArrowIds = useMemo(
|
||||
() => (localArrows ? Object.keys(localArrows).map(Number) : []),
|
||||
[localArrows],
|
||||
);
|
||||
|
||||
// Players (judge or not) act as participants; pure spectators don't.
|
||||
// Matches desktop: aConcede/aNextTurn are disabled when isSpectator() without
|
||||
// judge privileges (see tab_game.cpp concede enablement + player_menu.cpp
|
||||
// getLocalOrJudge gates).
|
||||
const isParticipant = gameId != null && game != null && !isSpectator;
|
||||
const isConceded = localPlayer?.properties.conceded ?? false;
|
||||
const canAdvance =
|
||||
gameId != null && game != null && isStarted &&
|
||||
(isJudge || game.activePlayerId === game.localPlayerId);
|
||||
const canLeave = gameId != null && game != null;
|
||||
const canConcede = isParticipant && !isConceded;
|
||||
const canUnconcede = isParticipant && isConceded;
|
||||
// Rolling dice is a player action; judges may also roll. Pure spectators
|
||||
// cannot (desktop exposes it through the player menu, which spectators
|
||||
// don't receive).
|
||||
const canRoll = gameId != null && (isParticipant || isJudge);
|
||||
const canKick = gameId != null && isHost && opponents.length > 0;
|
||||
const canRemoveArrows = hasLiveGame && localArrowIds.length > 0;
|
||||
|
||||
const handlePassTurn = () => {
|
||||
if (!canAdvance || !hasLiveGame) {
|
||||
return;
|
||||
}
|
||||
webClient.request.game.nextTurn(gameId);
|
||||
};
|
||||
|
||||
const handleReverseTurn = () => {
|
||||
if (!canAdvance || !hasLiveGame) {
|
||||
return;
|
||||
}
|
||||
webClient.request.game.reverseTurn(gameId);
|
||||
};
|
||||
|
||||
const handleNextPhase = () => {
|
||||
if (!canAdvance || !hasLiveGame) {
|
||||
return;
|
||||
}
|
||||
// Desktop wraps at 11 → 0 (the Phase enum is 0–10). When no phase is
|
||||
// active yet (activePhase < 0 during the pre-game lobby), advance to
|
||||
// Untap (0).
|
||||
const current = game.activePhase;
|
||||
const next = current >= 0 ? (current + 1) % 11 : 0;
|
||||
webClient.request.game.setActivePhase(gameId, { phase: next });
|
||||
};
|
||||
|
||||
const handleConcedeToggle = () => {
|
||||
if (!hasLiveGame || (!canConcede && !canUnconcede)) {
|
||||
return;
|
||||
}
|
||||
if (isConceded) {
|
||||
onRequestUnconcede();
|
||||
} else {
|
||||
onRequestConcede();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveArrows = () => {
|
||||
if (!canRemoveArrows) {
|
||||
return;
|
||||
}
|
||||
for (const arrowId of localArrowIds) {
|
||||
webClient.request.game.deleteArrow(gameId, { arrowId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeave = () => {
|
||||
if (!canLeave || !hasLiveGame) {
|
||||
return;
|
||||
}
|
||||
webClient.request.game.leaveGame(gameId);
|
||||
};
|
||||
|
||||
const handleToggleInvert = () => {
|
||||
if (settingsStatus !== LoadingState.READY) {
|
||||
return;
|
||||
}
|
||||
void updateSettings({ invertVerticalCoordinate: !invertVerticalCoordinate });
|
||||
};
|
||||
|
||||
const handleKick = (playerId: number) => {
|
||||
if (!hasLiveGame) {
|
||||
return;
|
||||
}
|
||||
webClient.request.game.kickFromGame(gameId, { playerId });
|
||||
setKickAnchor(null);
|
||||
};
|
||||
const {
|
||||
isHost,
|
||||
isConceded,
|
||||
invertVerticalCoordinate,
|
||||
settingsReady,
|
||||
canAdvance,
|
||||
canLeave,
|
||||
canConcede,
|
||||
canUnconcede,
|
||||
canRoll,
|
||||
canKick,
|
||||
canRemoveArrows,
|
||||
hasLiveGame,
|
||||
opponents,
|
||||
kickAnchor,
|
||||
setKickAnchor,
|
||||
handlePassTurn,
|
||||
handleReverseTurn,
|
||||
handleNextPhase,
|
||||
handleConcedeToggle,
|
||||
handleRemoveArrows,
|
||||
handleLeave,
|
||||
handleToggleInvert,
|
||||
handleKick,
|
||||
} = useTurnControls({ gameId, onRequestConcede, onRequestUnconcede });
|
||||
|
||||
return (
|
||||
<div className="turn-controls" data-testid="turn-controls">
|
||||
|
|
@ -216,7 +116,7 @@ function TurnControls({
|
|||
className={`turn-controls__btn${invertVerticalCoordinate ? ' turn-controls__btn--active' : ''}`}
|
||||
onClick={handleToggleInvert}
|
||||
aria-pressed={invertVerticalCoordinate}
|
||||
disabled={settingsStatus !== LoadingState.READY}
|
||||
disabled={!settingsReady}
|
||||
title="Flip battlefield row order (saved across sessions)"
|
||||
>
|
||||
Invert Rows
|
||||
|
|
|
|||
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 Check from '@mui/icons-material/Check';
|
||||
|
||||
import { useWebClient } from '@app/hooks';
|
||||
import { GameSelectors, useAppSelector } from '@app/store';
|
||||
import { App } from '@app/types';
|
||||
|
||||
import { useZoneContextMenu } from './useZoneContextMenu';
|
||||
|
||||
import './ZoneContextMenu.css';
|
||||
|
||||
export interface ZoneContextMenuProps {
|
||||
|
|
@ -22,89 +22,52 @@ export interface ZoneContextMenuProps {
|
|||
onRequestRevealZone: () => void;
|
||||
}
|
||||
|
||||
function ZoneContextMenu({
|
||||
isOpen,
|
||||
anchorPosition,
|
||||
gameId,
|
||||
playerId,
|
||||
zoneName,
|
||||
onClose,
|
||||
onRequestDrawN,
|
||||
onRequestDumpN,
|
||||
onRequestRevealTopN,
|
||||
onRequestRevealZone,
|
||||
}: ZoneContextMenuProps) {
|
||||
const webClient = useWebClient();
|
||||
function ZoneContextMenu(props: ZoneContextMenuProps) {
|
||||
const {
|
||||
isOpen,
|
||||
anchorPosition,
|
||||
zoneName,
|
||||
onClose,
|
||||
onRequestDrawN,
|
||||
onRequestDumpN,
|
||||
onRequestRevealTopN,
|
||||
onRequestRevealZone,
|
||||
} = props;
|
||||
const {
|
||||
ready,
|
||||
alwaysReveal,
|
||||
alwaysLook,
|
||||
handleDrawOne,
|
||||
handleShuffle,
|
||||
handleRevealTop,
|
||||
handleToggleAlwaysReveal,
|
||||
handleToggleAlwaysLook,
|
||||
runAndClose,
|
||||
} = useZoneContextMenu(props);
|
||||
|
||||
const zone = useAppSelector((state) =>
|
||||
playerId != null && zoneName != null
|
||||
? GameSelectors.getZone(state, gameId, playerId, zoneName)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (playerId == null || zoneName == null) {
|
||||
if (!ready) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const game = webClient.request.game;
|
||||
const alwaysReveal = zone?.alwaysRevealTopCard ?? false;
|
||||
const alwaysLook = zone?.alwaysLookAtTopCard ?? false;
|
||||
|
||||
// Close-then-act helpers (avoid duplicating onClose at every site).
|
||||
const run = (fn: () => void) => () => {
|
||||
fn();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDrawOne = () => {
|
||||
game.drawCards(gameId, { number: 1 });
|
||||
};
|
||||
|
||||
const handleShuffle = () => {
|
||||
game.shuffle(gameId, { zoneName: App.ZoneName.DECK, start: 0, end: -1 });
|
||||
};
|
||||
|
||||
const handleRevealTop = () => {
|
||||
game.revealCards(gameId, {
|
||||
zoneName: App.ZoneName.DECK,
|
||||
playerId: -1,
|
||||
topCards: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleAlwaysReveal = () => {
|
||||
game.changeZoneProperties(gameId, {
|
||||
zoneName: App.ZoneName.DECK,
|
||||
alwaysRevealTopCard: !alwaysReveal,
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleAlwaysLook = () => {
|
||||
game.changeZoneProperties(gameId, {
|
||||
zoneName: App.ZoneName.DECK,
|
||||
alwaysLookAtTopCard: !alwaysLook,
|
||||
});
|
||||
};
|
||||
|
||||
const menuItems: React.ReactNode[] = [];
|
||||
|
||||
if (zoneName === App.ZoneName.DECK) {
|
||||
menuItems.push(
|
||||
<MenuItem key="draw-one" onClick={run(handleDrawOne)}>Draw a card</MenuItem>,
|
||||
<MenuItem key="draw-n" onClick={run(onRequestDrawN)}>Draw N cards…</MenuItem>,
|
||||
<MenuItem key="shuffle" onClick={run(handleShuffle)}>Shuffle</MenuItem>,
|
||||
<MenuItem key="dump-n" onClick={run(onRequestDumpN)}>Dump top N…</MenuItem>,
|
||||
<MenuItem key="draw-one" onClick={runAndClose(handleDrawOne)}>Draw a card</MenuItem>,
|
||||
<MenuItem key="draw-n" onClick={runAndClose(onRequestDrawN)}>Draw N cards…</MenuItem>,
|
||||
<MenuItem key="shuffle" onClick={runAndClose(handleShuffle)}>Shuffle</MenuItem>,
|
||||
<MenuItem key="dump-n" onClick={runAndClose(onRequestDumpN)}>Dump top N…</MenuItem>,
|
||||
<Divider key="d1" />,
|
||||
<MenuItem key="reveal-top" onClick={run(handleRevealTop)}>
|
||||
<MenuItem key="reveal-top" onClick={runAndClose(handleRevealTop)}>
|
||||
Reveal top card to all
|
||||
</MenuItem>,
|
||||
<MenuItem key="reveal-top-n" onClick={run(onRequestRevealTopN)}>
|
||||
<MenuItem key="reveal-top-n" onClick={runAndClose(onRequestRevealTopN)}>
|
||||
Reveal top N to…
|
||||
</MenuItem>,
|
||||
<Divider key="d2" />,
|
||||
<MenuItem
|
||||
key="always-reveal"
|
||||
onClick={run(handleToggleAlwaysReveal)}
|
||||
onClick={runAndClose(handleToggleAlwaysReveal)}
|
||||
className="zone-context-menu__toggle"
|
||||
>
|
||||
<span className="zone-context-menu__check" aria-hidden>
|
||||
|
|
@ -114,7 +77,7 @@ function ZoneContextMenu({
|
|||
</MenuItem>,
|
||||
<MenuItem
|
||||
key="always-look"
|
||||
onClick={run(handleToggleAlwaysLook)}
|
||||
onClick={runAndClose(handleToggleAlwaysLook)}
|
||||
className="zone-context-menu__toggle"
|
||||
>
|
||||
<span className="zone-context-menu__check" aria-hidden>
|
||||
|
|
@ -125,13 +88,13 @@ function ZoneContextMenu({
|
|||
);
|
||||
} else if (zoneName === App.ZoneName.GRAVE) {
|
||||
menuItems.push(
|
||||
<MenuItem key="reveal-grave" onClick={run(onRequestRevealZone)}>
|
||||
<MenuItem key="reveal-grave" onClick={runAndClose(onRequestRevealZone)}>
|
||||
Reveal graveyard to…
|
||||
</MenuItem>,
|
||||
);
|
||||
} else if (zoneName === App.ZoneName.EXILE) {
|
||||
menuItems.push(
|
||||
<MenuItem key="reveal-exile" onClick={run(onRequestRevealZone)}>
|
||||
<MenuItem key="reveal-exile" onClick={runAndClose(onRequestRevealZone)}>
|
||||
Reveal exile to…
|
||||
</MenuItem>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 { useTranslation } from 'react-i18next';
|
||||
import { Select, MenuItem } from '@mui/material';
|
||||
|
|
@ -13,20 +12,13 @@ import AddIcon from '@mui/icons-material/Add';
|
|||
import EditRoundedIcon from '@mui/icons-material/Edit';
|
||||
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
|
||||
|
||||
import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
||||
import { KnownHostDialog } from '@app/dialogs';
|
||||
import { getHostPort, HostDTO } from '@app/services';
|
||||
import { ServerTypes } from '@app/store';
|
||||
import { App } from '@app/types';
|
||||
import Toast from '../Toast/Toast';
|
||||
|
||||
import './KnownHosts.css';
|
||||
import { TestConnection, useKnownHostsComponent } from './useKnownHostsComponent';
|
||||
|
||||
enum TestConnection {
|
||||
TESTING = 'testing',
|
||||
FAILED = 'failed',
|
||||
SUCCESS = 'success',
|
||||
}
|
||||
import './KnownHosts.css';
|
||||
|
||||
const PREFIX = 'KnownHosts';
|
||||
|
||||
|
|
@ -57,115 +49,31 @@ const Root = styled('div')(({ theme }) => ({
|
|||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const KnownHosts = (props: any) => {
|
||||
const { input, meta, disabled } = props;
|
||||
const onChange: (value: HostDTO) => void = input.onChange;
|
||||
const { touched, error, warning } = meta;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const webClient = useWebClient();
|
||||
const knownHosts = useKnownHosts();
|
||||
|
||||
const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({
|
||||
open: false,
|
||||
edit: null,
|
||||
});
|
||||
|
||||
const [testingConnection, setTestingConnection] = useState<TestConnection | null>(null);
|
||||
|
||||
const [showCreateToast, setShowCreateToast] = useState(false);
|
||||
const [showDeleteToast, setShowDeleteToast] = useState(false);
|
||||
const [showEditToast, setShowEditToast] = useState(false);
|
||||
|
||||
const selectedHost =
|
||||
knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined;
|
||||
const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : [];
|
||||
|
||||
const testConnection = (host: HostDTO) => {
|
||||
setTestingConnection(TestConnection.TESTING);
|
||||
webClient.request.authentication.testConnection({ ...getHostPort(host) });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedHost) {
|
||||
return;
|
||||
}
|
||||
onChange(selectedHost);
|
||||
testConnection(selectedHost);
|
||||
}, [selectedHost]);
|
||||
|
||||
useReduxEffect(
|
||||
() => {
|
||||
setTestingConnection(TestConnection.SUCCESS);
|
||||
},
|
||||
ServerTypes.TEST_CONNECTION_SUCCESSFUL,
|
||||
[]
|
||||
);
|
||||
|
||||
useReduxEffect(
|
||||
() => {
|
||||
setTestingConnection(TestConnection.FAILED);
|
||||
},
|
||||
ServerTypes.TEST_CONNECTION_FAILED,
|
||||
[]
|
||||
);
|
||||
|
||||
const onPick = async (host: HostDTO) => {
|
||||
if (knownHosts.status !== LoadingState.READY) {
|
||||
return;
|
||||
}
|
||||
onChange(host);
|
||||
await knownHosts.select(host.id!);
|
||||
testConnection(host);
|
||||
};
|
||||
|
||||
const openAddKnownHostDialog = () => {
|
||||
setDialogState((s) => ({ ...s, open: true, edit: null }));
|
||||
};
|
||||
|
||||
const openEditKnownHostDialog = (host: HostDTO) => {
|
||||
setDialogState((s) => ({ ...s, open: true, edit: host }));
|
||||
};
|
||||
|
||||
const closeKnownHostDialog = () => {
|
||||
setDialogState((s) => ({ ...s, open: false }));
|
||||
};
|
||||
|
||||
const handleDialogRemove = async ({ id }: { id: number }) => {
|
||||
if (knownHosts.status !== LoadingState.READY) {
|
||||
return;
|
||||
}
|
||||
await knownHosts.remove(id);
|
||||
closeKnownHostDialog();
|
||||
setShowDeleteToast(true);
|
||||
};
|
||||
|
||||
const handleDialogSubmit = async ({
|
||||
id,
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
}: {
|
||||
id?: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: string;
|
||||
}) => {
|
||||
if (knownHosts.status !== LoadingState.READY) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
await knownHosts.update(id, { name, host, port });
|
||||
setShowEditToast(true);
|
||||
} else {
|
||||
const newHost: App.Host = { name, host, port, editable: true };
|
||||
await knownHosts.add(newHost);
|
||||
setShowCreateToast(true);
|
||||
}
|
||||
|
||||
closeKnownHostDialog();
|
||||
};
|
||||
const {
|
||||
hosts,
|
||||
selectedHost,
|
||||
testingConnection,
|
||||
dialogState,
|
||||
showCreateToast,
|
||||
showDeleteToast,
|
||||
showEditToast,
|
||||
setShowCreateToast,
|
||||
setShowDeleteToast,
|
||||
setShowEditToast,
|
||||
onPick,
|
||||
openAddKnownHostDialog,
|
||||
openEditKnownHostDialog,
|
||||
closeKnownHostDialog,
|
||||
handleDialogRemove,
|
||||
handleDialogSubmit,
|
||||
} = useKnownHostsComponent({ onChange });
|
||||
|
||||
return (
|
||||
<Root className={'KnownHosts ' + classes.root}>
|
||||
|
|
|
|||
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 Popover from '@mui/material/Popover';
|
||||
|
||||
import { CardDTO, TokenDTO } from '@app/services';
|
||||
|
||||
import CardDetails from '../CardDetails/CardDetails';
|
||||
import TokenDetails from '../TokenDetails/TokenDetails';
|
||||
|
||||
import { useCardCallout } from './useCardCallout';
|
||||
|
||||
import './CardCallout.css';
|
||||
|
||||
const PREFIX = 'CardCallout';
|
||||
|
|
@ -28,31 +26,8 @@ const Root = styled('span')(() => ({
|
|||
}));
|
||||
|
||||
const CardCallout = ({ name }) => {
|
||||
const [card, setCard] = useState<CardDTO>(null);
|
||||
const [token, setToken] = useState<TokenDTO>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<Element>(null);
|
||||
|
||||
useMemo(async () => {
|
||||
const card = await CardDTO.get(name);
|
||||
if (card) {
|
||||
return setCard(card)
|
||||
}
|
||||
|
||||
const token = await TokenDTO.get(name);
|
||||
if (token) {
|
||||
return setToken(token);
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
const handlePopoverOpen = (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handlePopoverClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
const { card, token, anchorEl, open, handlePopoverOpen, handlePopoverClose } =
|
||||
useCardCallout(name);
|
||||
|
||||
return (
|
||||
<Root className='callout'>
|
||||
|
|
@ -81,8 +56,8 @@ const CardCallout = ({ name }) => {
|
|||
}}
|
||||
>
|
||||
<div className="callout-card">
|
||||
{ card && (<CardDetails card={card} />) }
|
||||
{ token && (<TokenDetails token={token} />) }
|
||||
{card && (<CardDetails card={card} />)}
|
||||
{token && (<TokenDetails token={token} />)}
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { NavLink, generatePath } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { App } from '@app/types';
|
||||
|
||||
import CardCallout from './CardCallout';
|
||||
import { useParsedMessage } from './useMessage';
|
||||
import './Message.css';
|
||||
|
||||
const Message = ({ message: { message } }) => (
|
||||
|
|
@ -17,23 +16,12 @@ const Message = ({ message: { message } }) => (
|
|||
);
|
||||
|
||||
const ParsedMessage = ({ message }) => {
|
||||
const [messageChunks, setMessageChunks] = useState(null);
|
||||
const [name, setName] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const name = message.match(App.MESSAGE_SENDER_REGEX);
|
||||
|
||||
if (name) {
|
||||
setName(name[1]);
|
||||
}
|
||||
|
||||
setMessageChunks(parseMessage(message));
|
||||
}, [message]);
|
||||
const { name, chunks } = useParsedMessage(message, parseChunks);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ name && (<strong><PlayerLink name={name} />:</strong>) }
|
||||
{ messageChunks }
|
||||
{name && (<strong><PlayerLink name={name} />:</strong>)}
|
||||
{chunks}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -44,14 +32,7 @@ const PlayerLink = ({ name, label = name }) => (
|
|||
</NavLink>
|
||||
);
|
||||
|
||||
function parseMessage(message) {
|
||||
return message.replace(App.MESSAGE_SENDER_REGEX, '')
|
||||
.split(App.CARD_CALLOUT_REGEX)
|
||||
.filter(chunk => !!chunk)
|
||||
.map(parseChunks);
|
||||
}
|
||||
|
||||
function parseChunks(chunk, index) {
|
||||
function parseChunks(chunk: string, index: number): ReactNode {
|
||||
if (chunk.match(App.CARD_CALLOUT_REGEX)) {
|
||||
const name = chunk.replace(App.CALLOUT_BOUNDARY_REGEX, '').trim();
|
||||
return (<CardCallout name={name} key={index}></CardCallout>);
|
||||
|
|
@ -68,9 +49,9 @@ function parseChunks(chunk, index) {
|
|||
return chunk;
|
||||
}
|
||||
|
||||
function parseUrlChunk(chunk) {
|
||||
function parseUrlChunk(chunk: string): ReactNode {
|
||||
return chunk.split(App.URL_REGEX)
|
||||
.filter(urlChunk => !!urlChunk)
|
||||
.filter((urlChunk) => !!urlChunk)
|
||||
.map((urlChunk, index) => {
|
||||
if (urlChunk.match(App.URL_REGEX)) {
|
||||
return (<a className='link' href={urlChunk} key={index} target='_blank' rel='noopener noreferrer'>{urlChunk}</a>);
|
||||
|
|
@ -80,9 +61,9 @@ function parseUrlChunk(chunk) {
|
|||
});
|
||||
}
|
||||
|
||||
function parseMentionChunk(chunk) {
|
||||
function parseMentionChunk(chunk: string): ReactNode {
|
||||
return chunk.split(App.MENTION_REGEX)
|
||||
.filter(mentionChunk => !!mentionChunk)
|
||||
.filter((mentionChunk) => !!mentionChunk)
|
||||
.map((mentionChunk, index) => {
|
||||
const mention = mentionChunk.match(App.MENTION_REGEX);
|
||||
|
||||
|
|
|
|||
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 Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
|
||||
import { Images } from '@app/images';
|
||||
import { useWebClient } from '@app/hooks';
|
||||
import { ServerSelectors } from '@app/store';
|
||||
import { App, Data } from '@app/types';
|
||||
import { useAppSelector } from '@app/store';
|
||||
|
||||
import { useUserDisplay } from './useUserDisplay';
|
||||
|
||||
import './UserDisplay.css';
|
||||
|
||||
interface UserDisplayProps {
|
||||
user: Data.ServerInfo_User;
|
||||
}
|
||||
|
||||
const UserDisplay = ({ user }: UserDisplayProps) => {
|
||||
const buddyList = useAppSelector(state => ServerSelectors.getBuddyList(state));
|
||||
const ignoreList = useAppSelector(state => ServerSelectors.getIgnoreList(state));
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const webClient = useWebClient();
|
||||
|
||||
const { name, country } = user;
|
||||
|
||||
const handleClick = (event) => {
|
||||
event.preventDefault();
|
||||
setPosition({ x: event.clientX + 2, y: event.clientY + 4 });
|
||||
};
|
||||
|
||||
const handleClose = () => setPosition(null);
|
||||
|
||||
const isABuddy = Boolean(buddyList[user.name]);
|
||||
const isIgnored = Boolean(ignoreList[user.name]);
|
||||
|
||||
const onAddBuddy = () => {
|
||||
webClient.request.session.addToBuddyList(user.name);
|
||||
handleClose();
|
||||
};
|
||||
const onRemoveBuddy = () => {
|
||||
webClient.request.session.removeFromBuddyList(user.name);
|
||||
handleClose();
|
||||
};
|
||||
const onAddIgnore = () => {
|
||||
webClient.request.session.addToIgnoreList(user.name);
|
||||
handleClose();
|
||||
};
|
||||
const onRemoveIgnore = () => {
|
||||
webClient.request.session.removeFromIgnoreList(user.name);
|
||||
handleClose();
|
||||
};
|
||||
const {
|
||||
position,
|
||||
isABuddy,
|
||||
isIgnored,
|
||||
handleClick,
|
||||
handleClose,
|
||||
onAddBuddy,
|
||||
onRemoveBuddy,
|
||||
onAddIgnore,
|
||||
onRemoveIgnore,
|
||||
} = useUserDisplay(name);
|
||||
|
||||
return (
|
||||
<div className="user-display">
|
||||
|
|
@ -87,8 +66,4 @@ const UserDisplay = ({ user }: UserDisplayProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
interface UserDisplayProps {
|
||||
user: Data.ServerInfo_User;
|
||||
}
|
||||
|
||||
export default UserDisplay;
|
||||
|
|
|
|||
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 Button from '@mui/material/Button';
|
||||
|
|
@ -6,49 +5,28 @@ import ListItemButton from '@mui/material/ListItemButton';
|
|||
import Paper from '@mui/material/Paper';
|
||||
|
||||
import { UserDisplay, VirtualList, AuthGuard, LanguageDropdown } from '@app/components';
|
||||
import { useWebClient } from '@app/hooks';
|
||||
import { ServerSelectors } from '@app/store';
|
||||
import Layout from '../Layout/Layout';
|
||||
import { useAppSelector } from '@app/store';
|
||||
|
||||
import AddToBuddies from './AddToBuddies';
|
||||
import AddToIgnore from './AddToIgnore';
|
||||
import { useAccount } from './useAccount';
|
||||
|
||||
import './Account.css';
|
||||
|
||||
const Account = () => {
|
||||
const buddyList = useAppSelector(state => ServerSelectors.getSortedBuddyList(state));
|
||||
const ignoreList = useAppSelector(state => ServerSelectors.getSortedIgnoreList(state));
|
||||
const serverName = useAppSelector(state => ServerSelectors.getName(state));
|
||||
const serverVersion = useAppSelector(state => ServerSelectors.getVersion(state));
|
||||
const user = useAppSelector(state => ServerSelectors.getUser(state));
|
||||
const webClient = useWebClient();
|
||||
const { country, realName, name, userLevel, accountageSecs, avatarBmp } = user || {};
|
||||
|
||||
const avatarUrl = useMemo(() => {
|
||||
if (!avatarBmp) {
|
||||
return '';
|
||||
}
|
||||
return URL.createObjectURL(new Blob([avatarBmp as BlobPart], { type: 'image/png' }));
|
||||
}, [avatarBmp]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (avatarUrl) {
|
||||
URL.revokeObjectURL(avatarUrl);
|
||||
}
|
||||
};
|
||||
}, [avatarUrl]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleAddToBuddies = ({ userName }) => {
|
||||
webClient.request.session.addToBuddyList(userName);
|
||||
};
|
||||
|
||||
const handleAddToIgnore = ({ userName }) => {
|
||||
webClient.request.session.addToIgnoreList(userName);
|
||||
};
|
||||
const {
|
||||
buddyList,
|
||||
ignoreList,
|
||||
serverName,
|
||||
serverVersion,
|
||||
user,
|
||||
avatarUrl,
|
||||
handleAddToBuddies,
|
||||
handleAddToIgnore,
|
||||
handleDisconnect,
|
||||
} = useAccount();
|
||||
const { country, realName, name, userLevel, accountageSecs } = user || {};
|
||||
|
||||
return (
|
||||
<Layout className="account">
|
||||
|
|
@ -59,11 +37,11 @@ const Account = () => {
|
|||
Buddies Online: ?/{buddyList.length}
|
||||
</div>
|
||||
<VirtualList
|
||||
items={ buddyList.map(user => (
|
||||
items={buddyList.map(user => (
|
||||
<ListItemButton key={user.name} dense>
|
||||
<UserDisplay user={user} />
|
||||
</ListItemButton>
|
||||
)) }
|
||||
))}
|
||||
/>
|
||||
<div style={{ borderTop: '1px solid' }}>
|
||||
<AddToBuddies onSubmit={handleAddToBuddies} />
|
||||
|
|
@ -76,11 +54,11 @@ const Account = () => {
|
|||
Ignored Users Online: ?/{ignoreList.length}
|
||||
</div>
|
||||
<VirtualList
|
||||
items={ ignoreList.map(user => (
|
||||
items={ignoreList.map(user => (
|
||||
<ListItemButton key={user.name} dense>
|
||||
<UserDisplay user={user} />
|
||||
</ListItemButton>
|
||||
)) }
|
||||
))}
|
||||
/>
|
||||
<div style={{ borderTop: '1px solid' }}>
|
||||
<AddToIgnore onSubmit={handleAddToIgnore} />
|
||||
|
|
@ -89,7 +67,7 @@ const Account = () => {
|
|||
</div>
|
||||
<div className="account-column overflow-scroll">
|
||||
<Paper className="account-details" style={{ margin: '0 0 5px 0' }}>
|
||||
{ avatarUrl && <img src={avatarUrl} alt={name} /> }
|
||||
{avatarUrl && <img src={avatarUrl} alt={name} />}
|
||||
<p><strong>{name}</strong></p>
|
||||
<p>Location: ({country?.toUpperCase()})</p>
|
||||
<p>User Level: {userLevel}</p>
|
||||
|
|
@ -105,12 +83,8 @@ const Account = () => {
|
|||
<Paper className="account-details">
|
||||
<p>Server Name: {serverName}</p>
|
||||
<p>Server Version: {serverVersion}</p>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={() => webClient.request.authentication.disconnect()}
|
||||
>
|
||||
{ t('Common.disconnect') }
|
||||
<Button color="primary" variant="contained" onClick={handleDisconnect}>
|
||||
{t('Common.disconnect')}
|
||||
</Button>
|
||||
|
||||
<div className="account-details__lang">
|
||||
|
|
@ -119,7 +93,7 @@ const Account = () => {
|
|||
</Paper>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Account;
|
||||
|
|
|
|||
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, useNavigate, generatePath } from 'react-router-dom';
|
||||
import { NavLink, generatePath } from 'react-router-dom';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
|
|
@ -9,75 +8,25 @@ import MailOutlineRoundedIcon from '@mui/icons-material/MailOutlineRounded';
|
|||
import MenuRoundedIcon from '@mui/icons-material/MenuRounded';
|
||||
|
||||
import { CardImportDialog } from '@app/dialogs';
|
||||
import { useWebClient } from '@app/hooks';
|
||||
import { Images } from '@app/images';
|
||||
import { RoomsSelectors, ServerSelectors } from '@app/store';
|
||||
import { App } from '@app/types';
|
||||
import { useAppSelector } from '@app/store';
|
||||
|
||||
import { useLeftNav } from './useLeftNav';
|
||||
|
||||
import './LeftNav.css';
|
||||
|
||||
interface LeftNavState {
|
||||
anchorEl: Element;
|
||||
showCardImportDialog: boolean;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
const LeftNav = () => {
|
||||
const joinedRooms = useAppSelector(state => RoomsSelectors.getJoinedRooms(state));
|
||||
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
|
||||
const isModerator = useAppSelector(ServerSelectors.getIsUserModerator);
|
||||
const navigate = useNavigate();
|
||||
const webClient = useWebClient();
|
||||
const [state, setState] = useState<LeftNavState>({
|
||||
anchorEl: null,
|
||||
showCardImportDialog: false,
|
||||
options: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let options: string[] = [
|
||||
'Account',
|
||||
'Replays',
|
||||
];
|
||||
|
||||
if (isModerator) {
|
||||
options = [
|
||||
...options,
|
||||
'Administration',
|
||||
'Logs'
|
||||
];
|
||||
}
|
||||
|
||||
setState(s => ({ ...s, options }));
|
||||
}, [isModerator]);
|
||||
|
||||
const handleMenuOpen = (event) => {
|
||||
setState(s => ({ ...s, anchorEl: event.target }));
|
||||
}
|
||||
|
||||
const handleMenuItemClick = (option: string) => {
|
||||
const route = App.RouteEnum[option.toUpperCase()];
|
||||
navigate(generatePath(route));
|
||||
}
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setState(s => ({ ...s, anchorEl: null }));
|
||||
}
|
||||
|
||||
const leaveRoom = (event, roomId) => {
|
||||
event.preventDefault();
|
||||
webClient.request.rooms.leaveRoom(roomId);
|
||||
};
|
||||
|
||||
const openImportCardWizard = () => {
|
||||
setState(s => ({ ...s, showCardImportDialog: true }));
|
||||
handleMenuClose();
|
||||
}
|
||||
|
||||
const closeImportCardWizard = () => {
|
||||
setState(s => ({ ...s, showCardImportDialog: false }));
|
||||
}
|
||||
const {
|
||||
joinedRooms,
|
||||
isConnected,
|
||||
state,
|
||||
handleMenuOpen,
|
||||
handleMenuItemClick,
|
||||
handleMenuClose,
|
||||
leaveRoom,
|
||||
openImportCardWizard,
|
||||
closeImportCardWizard,
|
||||
} = useLeftNav();
|
||||
|
||||
return (
|
||||
<div className="LeftNav__container">
|
||||
|
|
@ -86,11 +35,11 @@ const LeftNav = () => {
|
|||
<NavLink to={App.RouteEnum.SERVER}>
|
||||
<img src={Images.Logo} alt="logo" />
|
||||
</NavLink>
|
||||
{ isConnected && (
|
||||
{isConnected && (
|
||||
<span className="LeftNav-server__indicator"></span>
|
||||
) }
|
||||
)}
|
||||
</div>
|
||||
{ isConnected && (
|
||||
{isConnected && (
|
||||
<div className="LeftNav-content">
|
||||
<nav className="LeftNav-nav">
|
||||
<nav className="LeftNav-nav__links">
|
||||
|
|
@ -110,7 +59,7 @@ const LeftNav = () => {
|
|||
{joinedRooms.map((room) => (
|
||||
<div className="LeftNav-nav__link-menu__item" key={room.info.roomId}>
|
||||
<NavLink className="LeftNav-nav__link-menu__btn"
|
||||
to={ generatePath(App.RouteEnum.ROOM, { roomId: room.info.roomId.toString() }) }
|
||||
to={generatePath(App.RouteEnum.ROOM, { roomId: room.info.roomId.toString() })}
|
||||
>
|
||||
{room.info.name}
|
||||
|
||||
|
|
@ -123,13 +72,13 @@ const LeftNav = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div className="LeftNav-nav__link">
|
||||
<NavLink className="LeftNav-nav__link-btn" to={ App.RouteEnum.GAME }>
|
||||
<NavLink className="LeftNav-nav__link-btn" to={App.RouteEnum.GAME}>
|
||||
Games
|
||||
<ArrowDropDownIcon className="LeftNav-nav__link-btn__icon" fontSize="small" />
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="LeftNav-nav__link">
|
||||
<NavLink className="LeftNav-nav__link-btn" to={ App.RouteEnum.DECKS }>
|
||||
<NavLink className="LeftNav-nav__link-btn" to={App.RouteEnum.DECKS}>
|
||||
Decks
|
||||
<ArrowDropDownIcon className="LeftNav-nav__link-btn__icon" fontSize="small" />
|
||||
</NavLink>
|
||||
|
|
@ -173,7 +122,7 @@ const LeftNav = () => {
|
|||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
) }
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardImportDialog
|
||||
|
|
@ -182,6 +131,6 @@ const LeftNav = () => {
|
|||
></CardImportDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default LeftNav;
|
||||
|
|
|
|||
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 { useTranslation } from 'react-i18next';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
|
@ -9,28 +8,25 @@ import Typography from '@mui/material/Typography';
|
|||
import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog, AccountActivationDialog } from '@app/dialogs';
|
||||
import { LanguageDropdown } from '@app/components';
|
||||
import { LoginForm } from '@app/forms';
|
||||
import { useAutoLogin, useFireOnce, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
||||
import { Images } from '@app/images';
|
||||
import { getHostPort, serverProps } from '@app/services';
|
||||
import { serverProps } from '@app/services';
|
||||
import { App } from '@app/types';
|
||||
import { WebsocketTypes } from '@app/websocket/types';
|
||||
import { ServerSelectors, ServerTypes } from '@app/store';
|
||||
import Layout from '../Layout/Layout';
|
||||
import { useAppSelector } from '@app/store';
|
||||
|
||||
import { useLogin } from './useLogin';
|
||||
|
||||
import './Login.css';
|
||||
import { useToast } from '@app/components';
|
||||
|
||||
const PREFIX = 'Login';
|
||||
|
||||
const classes = {
|
||||
root: `${PREFIX}-root`
|
||||
root: `${PREFIX}-root`,
|
||||
};
|
||||
|
||||
const Root = styled('div')(({ theme }) => ({
|
||||
[`&.${classes.root}`]: {
|
||||
'& .login-content__header': {
|
||||
color: theme.palette.success.light
|
||||
color: theme.palette.success.light,
|
||||
},
|
||||
|
||||
'& .login-content__description': {
|
||||
|
|
@ -61,188 +57,36 @@ const Root = styled('div')(({ theme }) => ({
|
|||
display: 'flex',
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const Login = () => {
|
||||
const description = useAppSelector(s => ServerSelectors.getDescription(s));
|
||||
const isConnected = useAppSelector(ServerSelectors.getIsConnected);
|
||||
const connectionAttemptMade = useAppSelector(ServerSelectors.getConnectionAttemptMade);
|
||||
const webClient = useWebClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [pendingActivationOptions, setPendingActivationOptions] = useState<WebsocketTypes.PendingActivationContext | null>(null);
|
||||
|
||||
const rememberLoginRef = useRef<any>(null);
|
||||
const knownHosts = useKnownHosts();
|
||||
const [dialogState, setDialogState] = useState({
|
||||
passwordResetRequestDialog: false,
|
||||
resetPasswordDialog: false,
|
||||
registrationDialog: false,
|
||||
activationDialog: false,
|
||||
});
|
||||
const [userToResetPassword, setUserToResetPassword] = useState(null);
|
||||
|
||||
const passwordResetToast = useToast({ key: 'password-reset-success', children: t('LoginContainer.toasts.passwordResetSuccess') });
|
||||
const accountActivatedToast = useToast({
|
||||
key: 'account-activation-success',
|
||||
children: t('LoginContainer.toasts.accountActivationSuccess')
|
||||
});
|
||||
|
||||
useReduxEffect(() => {
|
||||
closeRequestPasswordResetDialog();
|
||||
openResetPasswordDialog();
|
||||
}, ServerTypes.RESET_PASSWORD_REQUESTED, []);
|
||||
|
||||
useReduxEffect(() => {
|
||||
passwordResetToast.openToast()
|
||||
closeResetPasswordDialog();
|
||||
}, ServerTypes.RESET_PASSWORD_SUCCESS, []);
|
||||
|
||||
useReduxEffect(() => {
|
||||
accountActivatedToast.openToast()
|
||||
closeActivateAccountDialog();
|
||||
setPendingActivationOptions(null);
|
||||
}, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []);
|
||||
|
||||
useReduxEffect(({ payload: { options } }) => {
|
||||
setPendingActivationOptions(options);
|
||||
closeRegistrationDialog();
|
||||
openActivateAccountDialog();
|
||||
}, ServerTypes.ACCOUNT_AWAITING_ACTIVATION, []);
|
||||
|
||||
useReduxEffect(() => {
|
||||
resetSubmitButton();
|
||||
}, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []);
|
||||
|
||||
useReduxEffect(({ payload: { options: { hashedPassword } } }) => {
|
||||
if (rememberLoginRef.current) {
|
||||
updateHost(hashedPassword, rememberLoginRef.current);
|
||||
}
|
||||
}, ServerTypes.LOGIN_SUCCESSFUL, []);
|
||||
|
||||
const showDescription = () => {
|
||||
return !isConnected && description?.length;
|
||||
};
|
||||
|
||||
const onSubmitLogin = useCallback((loginForm) => {
|
||||
rememberLoginRef.current = loginForm;
|
||||
const { userName, password, selectedHost, remember } = loginForm;
|
||||
|
||||
const options: Omit<WebsocketTypes.LoginConnectOptions, 'reason'> = {
|
||||
...getHostPort(selectedHost),
|
||||
userName,
|
||||
password,
|
||||
};
|
||||
|
||||
if (remember && !password) {
|
||||
options.hashedPassword = selectedHost.hashedPassword;
|
||||
}
|
||||
|
||||
webClient.request.authentication.login(options);
|
||||
}, []);
|
||||
|
||||
const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin);
|
||||
|
||||
useAutoLogin(handleLogin, connectionAttemptMade);
|
||||
|
||||
const updateHost = (hashedPassword, { selectedHost, remember, userName }) => {
|
||||
knownHosts.update(selectedHost.id, {
|
||||
remember,
|
||||
userName: remember ? userName : null,
|
||||
hashedPassword: remember ? hashedPassword : null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRegistrationDialogSubmit = (registerForm) => {
|
||||
rememberLoginRef.current = registerForm;
|
||||
const { userName, password, email, country, realName, selectedHost } = registerForm;
|
||||
|
||||
webClient.request.authentication.register({
|
||||
...getHostPort(selectedHost),
|
||||
userName,
|
||||
password,
|
||||
email,
|
||||
country,
|
||||
realName,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAccountActivationDialogSubmit = ({ token }) => {
|
||||
if (!pendingActivationOptions) {
|
||||
return;
|
||||
}
|
||||
webClient.request.authentication.activateAccount({
|
||||
host: pendingActivationOptions.host,
|
||||
port: pendingActivationOptions.port,
|
||||
userName: pendingActivationOptions.userName,
|
||||
token,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRequestPasswordResetDialogSubmit = (form) => {
|
||||
const { userName, email, selectedHost } = form;
|
||||
const { host, port } = getHostPort(selectedHost);
|
||||
|
||||
if (email) {
|
||||
webClient.request.authentication.resetPasswordChallenge({ userName, email, host, port });
|
||||
} else {
|
||||
setUserToResetPassword(userName);
|
||||
webClient.request.authentication.resetPasswordRequest({ userName, host, port });
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }) => {
|
||||
const { host, port } = getHostPort(selectedHost);
|
||||
|
||||
webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port });
|
||||
};
|
||||
|
||||
const skipTokenRequest = (userName) => {
|
||||
setUserToResetPassword(userName);
|
||||
|
||||
setDialogState(s => ({ ...s,
|
||||
passwordResetRequestDialog: false,
|
||||
resetPasswordDialog: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const closeRequestPasswordResetDialog = () => {
|
||||
setDialogState(s => ({ ...s, passwordResetRequestDialog: false }));
|
||||
}
|
||||
|
||||
const openRequestPasswordResetDialog = () => {
|
||||
setDialogState(s => ({ ...s, passwordResetRequestDialog: true }));
|
||||
}
|
||||
|
||||
const closeResetPasswordDialog = () => {
|
||||
setDialogState(s => ({ ...s, resetPasswordDialog: false }));
|
||||
}
|
||||
|
||||
const openResetPasswordDialog = () => {
|
||||
setDialogState(s => ({ ...s, resetPasswordDialog: true }));
|
||||
}
|
||||
|
||||
const closeRegistrationDialog = () => {
|
||||
setDialogState(s => ({ ...s, registrationDialog: false }));
|
||||
}
|
||||
|
||||
const openRegistrationDialog = () => {
|
||||
setDialogState(s => ({ ...s, registrationDialog: true }));
|
||||
}
|
||||
|
||||
const closeActivateAccountDialog = () => {
|
||||
setDialogState(s => ({ ...s, activationDialog: false }));
|
||||
};
|
||||
|
||||
const openActivateAccountDialog = () => {
|
||||
setDialogState(s => ({ ...s, activationDialog: true }));
|
||||
};
|
||||
const {
|
||||
description,
|
||||
isConnected,
|
||||
dialogState,
|
||||
userToResetPassword,
|
||||
submitButtonDisabled,
|
||||
handleLogin,
|
||||
showDescription,
|
||||
handleRegistrationDialogSubmit,
|
||||
handleAccountActivationDialogSubmit,
|
||||
handleRequestPasswordResetDialogSubmit,
|
||||
handleResetPasswordDialogSubmit,
|
||||
skipTokenRequest,
|
||||
closeRequestPasswordResetDialog,
|
||||
openRequestPasswordResetDialog,
|
||||
closeResetPasswordDialog,
|
||||
closeRegistrationDialog,
|
||||
openRegistrationDialog,
|
||||
closeActivateAccountDialog,
|
||||
} = useLogin();
|
||||
|
||||
return (
|
||||
<Layout showNav={false} noHeightLimit={true}>
|
||||
<Root className={'login overflow-scroll ' + classes.root}>
|
||||
{ isConnected && <Navigate to={App.RouteEnum.SERVER} />}
|
||||
{isConnected && <Navigate to={App.RouteEnum.SERVER} />}
|
||||
|
||||
<div className="login__wrapper">
|
||||
<Paper className="login-content">
|
||||
|
|
@ -251,8 +95,8 @@ const Login = () => {
|
|||
<img src={Images.Logo} alt="logo" />
|
||||
<span>COCKATRICE</span>
|
||||
</div>
|
||||
<Typography variant="h1">{ t('LoginContainer.header.title') }</Typography>
|
||||
<Typography variant="subtitle1">{ t('LoginContainer.header.subtitle') }</Typography>
|
||||
<Typography variant="h1">{t('LoginContainer.header.title')}</Typography>
|
||||
<Typography variant="subtitle1">{t('LoginContainer.header.subtitle')}</Typography>
|
||||
<div className="login-form">
|
||||
<LoginForm
|
||||
onSubmit={handleLogin}
|
||||
|
|
@ -261,30 +105,26 @@ const Login = () => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
showDescription() && (
|
||||
<Paper className="login-content__connectionStatus">
|
||||
{description}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
{showDescription() && (
|
||||
<Paper className="login-content__connectionStatus">
|
||||
{description}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<div className="login-footer">
|
||||
<div className="login-footer__register">
|
||||
<span>{ t('LoginContainer.footer.registerPrompt') }</span>
|
||||
<Button color="primary" onClick={openRegistrationDialog}>{ t('LoginContainer.footer.registerAction') }</Button>
|
||||
<span>{t('LoginContainer.footer.registerPrompt')}</span>
|
||||
<Button color="primary" onClick={openRegistrationDialog}>{t('LoginContainer.footer.registerAction')}</Button>
|
||||
</div>
|
||||
<Typography variant="subtitle2">
|
||||
{ t('LoginContainer.footer.credit') } - { new Date().getUTCFullYear() }
|
||||
{t('LoginContainer.footer.credit')} - {new Date().getUTCFullYear()}
|
||||
</Typography>
|
||||
|
||||
{
|
||||
serverProps.REACT_APP_VERSION && (
|
||||
<Typography variant="subtitle2">
|
||||
{ t('LoginContainer.footer.version') }: { serverProps.REACT_APP_VERSION }
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
{serverProps.REACT_APP_VERSION && (
|
||||
<Typography variant="subtitle2">
|
||||
{t('LoginContainer.footer.version')}: {serverProps.REACT_APP_VERSION}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<div className="login-footer__language">
|
||||
<LanguageDropdown />
|
||||
|
|
@ -321,9 +161,8 @@ const Login = () => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{ /*<img src={loginGraphic} className="login-content__description-image"/>*/}
|
||||
<p className="login-content__description-subtitle1">{ t('LoginContainer.content.subtitle1') }</p>
|
||||
<p className="login-content__description-subtitle2">{ t('LoginContainer.content.subtitle2') }</p>
|
||||
<p className="login-content__description-subtitle1">{t('LoginContainer.content.subtitle1')}</p>
|
||||
<p className="login-content__description-subtitle2">{t('LoginContainer.content.subtitle2')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
|
|
@ -357,6 +196,6 @@ const Login = () => {
|
|||
</Root>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Login;
|
||||
|
|
|
|||
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 Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
|
|
@ -12,6 +10,8 @@ import Tab from '@mui/material/Tab';
|
|||
import Tabs from '@mui/material/Tabs';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import { useLogResults } from './useLogResults';
|
||||
|
||||
import './LogResults.css';
|
||||
|
||||
const LogResults = (props) => {
|
||||
|
|
@ -21,31 +21,15 @@ const LogResults = (props) => {
|
|||
const hasGameLogs = logs.game && logs.game.length;
|
||||
const hasChatLogs = logs.chat && logs.chat.length;
|
||||
|
||||
const [value, setValue] = React.useState(0);
|
||||
|
||||
const handleChange = (event, newValue) => {
|
||||
setValue(newValue);
|
||||
};
|
||||
const { value, handleChange } = useLogResults();
|
||||
|
||||
const headerCells = [
|
||||
{
|
||||
label: 'Time'
|
||||
},
|
||||
{
|
||||
label: 'Sender Name'
|
||||
},
|
||||
{
|
||||
label: 'Sender IP'
|
||||
},
|
||||
{
|
||||
label: 'Message'
|
||||
},
|
||||
{
|
||||
label: 'Target ID'
|
||||
},
|
||||
{
|
||||
label: 'Target Name'
|
||||
}
|
||||
{ label: 'Time' },
|
||||
{ label: 'Sender Name' },
|
||||
{ label: 'Sender IP' },
|
||||
{ label: 'Message' },
|
||||
{ label: 'Target ID' },
|
||||
{ label: 'Target Name' },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -67,7 +51,7 @@ const LogResults = (props) => {
|
|||
<Results logs={logs.chat} headerCells={headerCells} />
|
||||
</TabPanel>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const a11yProps = index => {
|
||||
|
|
@ -97,13 +81,13 @@ const Results = ({ headerCells, logs }) => (
|
|||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{ headerCells.map(({ label }) => (
|
||||
{headerCells.map(({ label }) => (
|
||||
<TableCell key={label}>{label}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ logs.map(({ time, senderName, senderIp, message, targetId, targetName }, index) => (
|
||||
{logs.map(({ time, senderName, senderIp, message, targetId, targetName }, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{time}</TableCell>
|
||||
<TableCell>{senderName}</TableCell>
|
||||
|
|
|
|||
|
|
@ -1,61 +1,13 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { AuthGuard, ModGuard } from '@app/components';
|
||||
import { SearchForm } from '@app/forms';
|
||||
import { useWebClient } from '@app/hooks';
|
||||
import { ServerDispatch, ServerSelectors } from '@app/store';
|
||||
import { Data } from '@app/types';
|
||||
import { useAppSelector } from '@app/store';
|
||||
|
||||
import LogResults from './LogResults';
|
||||
import { useLogs } from './useLogs';
|
||||
|
||||
import './Logs.css';
|
||||
|
||||
const Logs = () => {
|
||||
const logs = useAppSelector(state => ServerSelectors.getLogs(state));
|
||||
const webClient = useWebClient();
|
||||
const MAXIMUM_RESULTS = 1000;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
ServerDispatch.clearLogs();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const trimFields = (fields) => {
|
||||
const result: any = {};
|
||||
for (const [key, field] of Object.entries(fields)) {
|
||||
if (typeof field === 'string') {
|
||||
const trimmed = field.trim();
|
||||
if (trimmed) {
|
||||
result[key] = trimmed;
|
||||
}
|
||||
} else {
|
||||
result[key] = field;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const flattenLogLocations = (logLocations) => Object.keys(logLocations);
|
||||
|
||||
const onSubmit = (fields: Data.ViewLogHistoryParams) => {
|
||||
const trimmedFields: any = trimFields(fields);
|
||||
const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields;
|
||||
|
||||
const required = [userName, ipAddress, gameName, gameId, message].filter(Boolean);
|
||||
|
||||
if (logLocation) {
|
||||
trimmedFields.logLocation = flattenLogLocations(logLocation);
|
||||
}
|
||||
|
||||
trimmedFields.maximumResults = MAXIMUM_RESULTS;
|
||||
|
||||
if (required.length) {
|
||||
webClient.request.moderator.viewLogHistory(trimmedFields);
|
||||
} else {
|
||||
// @TODO use yet-to-be-implemented banner/alert
|
||||
}
|
||||
};
|
||||
const { logs, onSubmit } = useLogs();
|
||||
|
||||
return (
|
||||
<div className="moderator-logs overflow-scroll">
|
||||
|
|
@ -74,13 +26,3 @@ const Logs = () => {
|
|||
};
|
||||
|
||||
export default Logs;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
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 {
|
||||
renderWithProviders,
|
||||
|
|
@ -241,12 +241,12 @@ describe('GameSelector', () => {
|
|||
});
|
||||
|
||||
mockNavigate.mockClear();
|
||||
store.dispatch({
|
||||
type: GameTypes.GAME_JOINED,
|
||||
payload: { data: { gameInfo: { gameId: 42 }, hostId: 0, playerId: 0, spectator: false } },
|
||||
await act(async () => {
|
||||
store.dispatch({
|
||||
type: GameTypes.GAME_JOINED,
|
||||
payload: { data: { gameInfo: { gameId: 42 }, hostId: 0, playerId: 0, spectator: false } },
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(mockNavigate).toHaveBeenCalledWith(App.RouteEnum.GAME);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
// eslint-disable-next-line
|
||||
import React from "react";
|
||||
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
|
|
@ -9,11 +6,9 @@ import TableRow from '@mui/material/TableRow';
|
|||
import TableSortLabel from '@mui/material/TableSortLabel';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
|
||||
// import { RoomsService } from "AppShell/common/services";
|
||||
|
||||
import { SortUtil, RoomsDispatch, RoomsSelectors } from '@app/store';
|
||||
import { UserDisplay } from '@app/components';
|
||||
import { useAppSelector } from '@app/store';
|
||||
|
||||
import { useGames } from './useGames';
|
||||
|
||||
import './Games.css';
|
||||
|
||||
|
|
@ -24,8 +19,7 @@ interface GamesProps {
|
|||
|
||||
const Games = ({ room }: GamesProps) => {
|
||||
const roomId = room.info.roomId;
|
||||
const sortBy = useAppSelector(state => RoomsSelectors.getSortGamesBy(state));
|
||||
const sortedGames = useAppSelector(state => RoomsSelectors.getSortedRoomGames(state, roomId));
|
||||
const { sortBy, games, handleSort } = useGames(roomId);
|
||||
|
||||
const headerCells = [
|
||||
{ label: 'Age', field: 'info.startTime' },
|
||||
|
|
@ -37,30 +31,12 @@ const Games = ({ room }: GamesProps) => {
|
|||
{ label: 'Spectators', field: 'info.spectatorsCount' },
|
||||
];
|
||||
|
||||
const handleSort = (sortByField) => {
|
||||
const { field, order } = SortUtil.toggleSortBy(sortByField, sortBy);
|
||||
RoomsDispatch.sortGames(roomId, field, order);
|
||||
};
|
||||
|
||||
const isUnavailableGame = ({ started, maxPlayers, playerCount }) =>
|
||||
!started && playerCount < maxPlayers;
|
||||
|
||||
const isPasswordProtectedGame = ({ withPassword }) => !withPassword;
|
||||
|
||||
const isBuddiesOnlyGame = ({ onlyBuddies }) => !onlyBuddies;
|
||||
|
||||
const games = sortedGames.filter(game => (
|
||||
isUnavailableGame(game.info) &&
|
||||
isPasswordProtectedGame(game.info) &&
|
||||
isBuddiesOnlyGame(game.info)
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="games">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{ headerCells.map(({ label, field }) => {
|
||||
{headerCells.map(({ label, field }) => {
|
||||
const active = field === sortBy.field;
|
||||
const order = sortBy.order.toLowerCase();
|
||||
const sortDirection = active ? (order === 'asc' ? 'asc' : 'desc') : false;
|
||||
|
|
@ -82,7 +58,7 @@ const Games = ({ room }: GamesProps) => {
|
|||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ games.map((game) => {
|
||||
{games.map((game) => {
|
||||
const { info, gameType } = game;
|
||||
const { description, gameId, creatorInfo, maxPlayers, playerCount, spectatorsCount, startTime } = info;
|
||||
return (
|
||||
|
|
@ -96,7 +72,7 @@ const Games = ({ room }: GamesProps) => {
|
|||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell className="games-header__cell">
|
||||
<UserDisplay user={ creatorInfo } />
|
||||
<UserDisplay user={creatorInfo} />
|
||||
</TableCell>
|
||||
<TableCell className="games-header__cell single-line-ellipsis">{gameType}</TableCell>
|
||||
<TableCell className="games-header__cell single-line-ellipsis">?</TableCell>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import React from 'react';
|
||||
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
|
|
@ -8,11 +6,11 @@ import TableRow from '@mui/material/TableRow';
|
|||
import TableSortLabel from '@mui/material/TableSortLabel';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
|
||||
import { SortUtil, RoomsDispatch, RoomsSelectors } from '@app/store';
|
||||
import { UserDisplay } from '@app/components';
|
||||
import { useAppSelector } from '@app/store';
|
||||
import { Data, Enriched } from '@app/types';
|
||||
|
||||
import { useOpenGames } from './useOpenGames';
|
||||
|
||||
import './OpenGames.css';
|
||||
|
||||
interface OpenGamesProps {
|
||||
|
|
@ -56,9 +54,10 @@ function formatSpectators(info: Data.ServerInfo_Game): string {
|
|||
|
||||
const OpenGames = ({ room, onActivateGame }: OpenGamesProps) => {
|
||||
const roomId = room.info.roomId;
|
||||
const sortBy = useAppSelector(state => RoomsSelectors.getSortGamesBy(state));
|
||||
const games = useAppSelector(state => RoomsSelectors.getFilteredRoomGames(state, roomId));
|
||||
const selectedGameId = useAppSelector(state => RoomsSelectors.getSelectedGameId(state, roomId));
|
||||
const { sortBy, games, selectedGameId, handleSort, handleSelect, handleActivate } = useOpenGames({
|
||||
roomId,
|
||||
onActivateGame,
|
||||
});
|
||||
|
||||
const headerCells = [
|
||||
{ label: 'Age', field: 'info.startTime' },
|
||||
|
|
@ -70,26 +69,12 @@ const OpenGames = ({ room, onActivateGame }: OpenGamesProps) => {
|
|||
{ label: 'Spectators', field: 'info.spectatorsCount' },
|
||||
];
|
||||
|
||||
const handleSort = (sortByField) => {
|
||||
const { field, order } = SortUtil.toggleSortBy(sortByField, sortBy);
|
||||
RoomsDispatch.sortGames(roomId, field, order);
|
||||
};
|
||||
|
||||
const handleSelect = (gameId: number) => {
|
||||
RoomsDispatch.selectGame(roomId, gameId);
|
||||
};
|
||||
|
||||
const handleActivate = (gameId: number) => {
|
||||
RoomsDispatch.selectGame(roomId, gameId);
|
||||
onActivateGame?.(gameId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="games">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{ headerCells.map(({ label, field }) => {
|
||||
{headerCells.map(({ label, field }) => {
|
||||
const active = field === sortBy.field;
|
||||
const order = sortBy.order.toLowerCase();
|
||||
const sortDirection = active ? (order === 'asc' ? 'asc' : 'desc') : false;
|
||||
|
|
@ -111,7 +96,7 @@ const OpenGames = ({ room, onActivateGame }: OpenGamesProps) => {
|
|||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ games.map((game: Enriched.Game) => {
|
||||
{games.map((game: Enriched.Game) => {
|
||||
const { info, gameType } = game;
|
||||
const { description, gameId, creatorInfo, maxPlayers, playerCount, startTime } = info;
|
||||
const isSelected = gameId === selectedGameId;
|
||||
|
|
|
|||
|
|
@ -1,51 +1,23 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useNavigate, useParams, generatePath } from 'react-router-dom';
|
||||
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import Paper from '@mui/material/Paper';
|
||||
|
||||
import { ScrollToBottomOnChanges, ThreePaneLayout, UserDisplay, VirtualList, AuthGuard } from '@app/components';
|
||||
import { useWebClient } from '@app/hooks';
|
||||
import { RoomsSelectors } from '@app/store';
|
||||
import { useAppSelector } from '@app/store';
|
||||
import { App } from '@app/types';
|
||||
import Layout from '../Layout/Layout';
|
||||
|
||||
import GameSelector from './GameSelector/GameSelector';
|
||||
import Messages from './Messages';
|
||||
import SayMessage from './SayMessage';
|
||||
import { useRoom } from './useRoom';
|
||||
|
||||
import './Room.css';
|
||||
|
||||
const Room = () => {
|
||||
const joined = useAppSelector(state => RoomsSelectors.getJoinedRooms(state));
|
||||
const rooms = useAppSelector(state => RoomsSelectors.getRooms(state));
|
||||
const messages = useAppSelector(state => RoomsSelectors.getMessages(state));
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
|
||||
const roomId = parseInt(params.roomId, 10);
|
||||
const room = rooms[roomId];
|
||||
const roomMessages = messages[roomId];
|
||||
const users = useAppSelector(state => RoomsSelectors.getSortedRoomUsers(state, roomId));
|
||||
const webClient = useWebClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!joined.find(r => r.info.roomId === roomId)) {
|
||||
navigate(generatePath(App.RouteEnum.SERVER));
|
||||
}
|
||||
}, [joined]);
|
||||
const { room, roomMessages, users, handleRoomSay } = useRoom();
|
||||
|
||||
if (!room) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleRoomSay = ({ message }) => {
|
||||
if (message) {
|
||||
webClient.request.rooms.roomSay(roomId, message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout className="room-view">
|
||||
<AuthGuard />
|
||||
|
|
@ -76,15 +48,15 @@ const Room = () => {
|
|||
side={(
|
||||
<Paper className="room-view__side overflow-scroll">
|
||||
<div className="room-view__side-label">
|
||||
Users in this room: {users.length}
|
||||
Users in this room: {users.length}
|
||||
</div>
|
||||
<VirtualList
|
||||
className="room-view__side-list"
|
||||
items={ users.map(user => (
|
||||
items={users.map(user => (
|
||||
<ListItemButton key={user.name} className="room-view__side-list__item">
|
||||
<UserDisplay user={user} />
|
||||
</ListItemButton>
|
||||
)) }
|
||||
))}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
|
|
@ -92,6 +64,6 @@ const Room = () => {
|
|||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Room;
|
||||
|
|
|
|||
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 Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
|
|
@ -11,6 +10,8 @@ import TextField from '@mui/material/TextField';
|
|||
import { App } from '@app/types';
|
||||
import { cx } from '@app/utils';
|
||||
|
||||
import { useCreateCounterDialog } from './useCreateCounterDialog';
|
||||
|
||||
import './CreateCounterDialog.css';
|
||||
|
||||
const PREFIX = 'CreateCounterDialog';
|
||||
|
|
@ -58,26 +59,8 @@ const SWATCHES: ReadonlyArray<Swatch> = [
|
|||
];
|
||||
|
||||
function CreateCounterDialog({ isOpen, onSubmit, onCancel }: CreateCounterDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName('');
|
||||
setSelectedIdx(0);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
||||
e?.preventDefault();
|
||||
if (name.trim().length === 0) {
|
||||
setError('Name is required');
|
||||
return;
|
||||
}
|
||||
onSubmit({ name: name.trim(), color: SWATCHES[selectedIdx].color });
|
||||
};
|
||||
const { name, selectedIdx, error, handleNameChange, setSelectedIdx, handleSubmit } =
|
||||
useCreateCounterDialog({ isOpen, swatches: SWATCHES, onSubmit });
|
||||
|
||||
return (
|
||||
<StyledDialog
|
||||
|
|
@ -100,12 +83,7 @@ function CreateCounterDialog({ isOpen, onSubmit, onCancel }: CreateCounterDialog
|
|||
size="small"
|
||||
label="Counter name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
if (error) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
error={error != null}
|
||||
helperText={error ?? ''}
|
||||
slotProps={{ htmlInput: { 'aria-label': 'Counter name' } }}
|
||||
|
|
|
|||
|
|
@ -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 Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
|
|
@ -14,6 +13,13 @@ import MenuItem from '@mui/material/MenuItem';
|
|||
import InputLabel from '@mui/material/InputLabel';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
|
||||
import {
|
||||
MAX_ANNOTATION_LEN,
|
||||
MAX_NAME_LEN,
|
||||
MAX_PT_LEN,
|
||||
useCreateTokenDialog,
|
||||
} from './useCreateTokenDialog';
|
||||
|
||||
import './CreateTokenDialog.css';
|
||||
|
||||
const PREFIX = 'CreateTokenDialog';
|
||||
|
|
@ -58,51 +64,23 @@ const COLOR_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
|
|||
{ value: '', label: 'Colorless' },
|
||||
];
|
||||
|
||||
const DEFAULT_COLOR = 'w';
|
||||
|
||||
// Desktop server-side MAX_NAME_LENGTH is 0xff (255). Client-side caps on
|
||||
// the other free-text fields mirror that to prevent oversize-payload
|
||||
// round-trips that the server would reject anyway.
|
||||
const MAX_NAME_LEN = 255;
|
||||
const MAX_PT_LEN = 255;
|
||||
const MAX_ANNOTATION_LEN = 255;
|
||||
|
||||
function CreateTokenDialog({ isOpen, onSubmit, onCancel }: CreateTokenDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState(DEFAULT_COLOR);
|
||||
const [pt, setPT] = useState('');
|
||||
const [annotation, setAnnotation] = useState('');
|
||||
const [destroyOnZoneChange, setDestroyOnZoneChange] = useState(true);
|
||||
const [faceDown, setFaceDown] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName('');
|
||||
setColor(DEFAULT_COLOR);
|
||||
setPT('');
|
||||
setAnnotation('');
|
||||
setDestroyOnZoneChange(true);
|
||||
setFaceDown(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
||||
e?.preventDefault();
|
||||
if (name.trim().length === 0) {
|
||||
setError('Name is required');
|
||||
return;
|
||||
}
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
color,
|
||||
pt: pt.trim(),
|
||||
annotation: annotation.trim(),
|
||||
destroyOnZoneChange,
|
||||
faceDown,
|
||||
});
|
||||
};
|
||||
const {
|
||||
name,
|
||||
color,
|
||||
pt,
|
||||
annotation,
|
||||
destroyOnZoneChange,
|
||||
faceDown,
|
||||
error,
|
||||
handleNameChange,
|
||||
setColor,
|
||||
setPT,
|
||||
setAnnotation,
|
||||
setDestroyOnZoneChange,
|
||||
setFaceDown,
|
||||
handleSubmit,
|
||||
} = useCreateTokenDialog({ isOpen, onSubmit });
|
||||
|
||||
return (
|
||||
<StyledDialog
|
||||
|
|
@ -125,12 +103,7 @@ function CreateTokenDialog({ isOpen, onSubmit, onCancel }: CreateTokenDialogProp
|
|||
size="small"
|
||||
label="Token name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value.slice(0, MAX_NAME_LEN));
|
||||
if (error) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
error={error != null}
|
||||
helperText={error ?? ''}
|
||||
slotProps={{ htmlInput: { 'aria-label': 'Token name', maxLength: MAX_NAME_LEN } }}
|
||||
|
|
|
|||
|
|
@ -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 Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
|
|
@ -6,8 +5,7 @@ import DialogTitle from '@mui/material/DialogTitle';
|
|||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
|
||||
import { useWebClient } from '@app/hooks';
|
||||
import { GameSelectors, useAppSelector } from '@app/store';
|
||||
import { useDeckSelectDialog } from './useDeckSelectDialog';
|
||||
|
||||
import './DeckSelectDialog.css';
|
||||
|
||||
|
|
@ -40,34 +38,16 @@ export interface DeckSelectDialogProps {
|
|||
}
|
||||
|
||||
function DeckSelectDialog({ isOpen, gameId, handleClose }: DeckSelectDialogProps) {
|
||||
const webClient = useWebClient();
|
||||
const localPlayer = useAppSelector((state) =>
|
||||
gameId != null ? GameSelectors.getLocalPlayer(state, gameId) : undefined,
|
||||
);
|
||||
const [deckText, setDeckText] = useState('');
|
||||
|
||||
const deckHash = localPlayer?.properties.deckHash ?? '';
|
||||
const isReady = localPlayer?.properties.readyStart ?? false;
|
||||
const hasLocalPlayer = localPlayer != null;
|
||||
// Guard Submit/Ready on having a local player — today the deckSelectOpen
|
||||
// predicate in Game.tsx implies one, but the dialog mounts before the
|
||||
// Event_GameJoined echo populates players during reconnect.
|
||||
const canSubmit = hasLocalPlayer && deckText.trim().length > 0;
|
||||
const canToggleReady = hasLocalPlayer && deckHash.length > 0;
|
||||
|
||||
const handleSubmitDeck = () => {
|
||||
if (!canSubmit || gameId == null) {
|
||||
return;
|
||||
}
|
||||
webClient.request.game.deckSelect(gameId, { deck: deckText.trim() });
|
||||
};
|
||||
|
||||
const handleToggleReady = () => {
|
||||
if (!canToggleReady || gameId == null) {
|
||||
return;
|
||||
}
|
||||
webClient.request.game.readyStart(gameId, { ready: !isReady });
|
||||
};
|
||||
const {
|
||||
deckText,
|
||||
setDeckText,
|
||||
deckHash,
|
||||
isReady,
|
||||
canSubmit,
|
||||
canToggleReady,
|
||||
handleSubmitDeck,
|
||||
handleToggleReady,
|
||||
} = useDeckSelectDialog(gameId);
|
||||
|
||||
return (
|
||||
<StyledDialog
|
||||
|
|
|
|||
|
|
@ -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 Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
|
|
@ -8,6 +7,8 @@ import Typography from '@mui/material/Typography';
|
|||
import Button from '@mui/material/Button';
|
||||
import TextField from '@mui/material/TextField';
|
||||
|
||||
import { usePromptDialog } from './usePromptDialog';
|
||||
|
||||
import './PromptDialog.css';
|
||||
|
||||
const PREFIX = 'PromptDialog';
|
||||
|
|
@ -48,27 +49,12 @@ function PromptDialog({
|
|||
onSubmit,
|
||||
onCancel,
|
||||
}: PromptDialogProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setValue(initialValue);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, initialValue]);
|
||||
|
||||
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
||||
e?.preventDefault();
|
||||
if (validate) {
|
||||
const message = validate(value);
|
||||
if (message) {
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
onSubmit(value);
|
||||
};
|
||||
const { value, error, handleChange, handleSubmit } = usePromptDialog({
|
||||
isOpen,
|
||||
initialValue,
|
||||
validate,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledDialog
|
||||
|
|
@ -91,12 +77,7 @@ function PromptDialog({
|
|||
size="small"
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
if (error) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
error={error != null}
|
||||
helperText={error ?? helperText ?? ''}
|
||||
slotProps={{ htmlInput: { 'aria-label': label } }}
|
||||
|
|
|
|||
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 Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
|
|
@ -12,6 +11,8 @@ import MenuItem from '@mui/material/MenuItem';
|
|||
import InputLabel from '@mui/material/InputLabel';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
|
||||
import { useRevealCardsDialog } from './useRevealCardsDialog';
|
||||
|
||||
import './RevealCardsDialog.css';
|
||||
|
||||
const PREFIX = 'RevealCardsDialog';
|
||||
|
|
@ -59,31 +60,14 @@ function RevealCardsDialog({
|
|||
onSubmit,
|
||||
onCancel,
|
||||
}: RevealCardsDialogProps) {
|
||||
const [targetPlayerId, setTargetPlayerId] = useState<number>(ALL_PLAYERS);
|
||||
const [countDraft, setCountDraft] = useState(String(defaultCount));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTargetPlayerId(ALL_PLAYERS);
|
||||
setCountDraft(String(defaultCount));
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, defaultCount]);
|
||||
|
||||
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
||||
e?.preventDefault();
|
||||
let topCards = -1;
|
||||
if (showCountInput) {
|
||||
const n = Number(countDraft);
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
setError('Enter a positive integer');
|
||||
return;
|
||||
}
|
||||
topCards = n;
|
||||
}
|
||||
onSubmit({ targetPlayerId, topCards });
|
||||
};
|
||||
const {
|
||||
targetPlayerId,
|
||||
countDraft,
|
||||
error,
|
||||
setTargetPlayerId,
|
||||
handleCountChange,
|
||||
handleSubmit,
|
||||
} = useRevealCardsDialog({ isOpen, showCountInput, defaultCount, onSubmit });
|
||||
|
||||
return (
|
||||
<StyledDialog
|
||||
|
|
@ -128,12 +112,7 @@ function RevealCardsDialog({
|
|||
type="number"
|
||||
label="How many?"
|
||||
value={countDraft}
|
||||
onChange={(e) => {
|
||||
setCountDraft(e.target.value);
|
||||
if (error) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => handleCountChange(e.target.value)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
error={error != null}
|
||||
helperText={error ?? 'Enter a positive integer'}
|
||||
|
|
|
|||
|
|
@ -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 Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
|
|
@ -8,6 +7,8 @@ import Typography from '@mui/material/Typography';
|
|||
import Button from '@mui/material/Button';
|
||||
import TextField from '@mui/material/TextField';
|
||||
|
||||
import { useRollDieDialog } from './useRollDieDialog';
|
||||
|
||||
import './RollDieDialog.css';
|
||||
|
||||
const PREFIX = 'RollDieDialog';
|
||||
|
|
@ -43,32 +44,8 @@ function RollDieDialog({
|
|||
onSubmit,
|
||||
onCancel,
|
||||
}: RollDieDialogProps) {
|
||||
const [sides, setSides] = useState(String(lastSides));
|
||||
const [count, setCount] = useState(String(lastCount));
|
||||
const [error, setError] = useState<{ field: 'sides' | 'count'; message: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSides(String(lastSides));
|
||||
setCount(String(lastCount));
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, lastSides, lastCount]);
|
||||
|
||||
const handleSubmit = (e?: React.FormEvent<HTMLFormElement>) => {
|
||||
e?.preventDefault();
|
||||
const s = Number(sides);
|
||||
if (!Number.isInteger(s) || s < 1) {
|
||||
setError({ field: 'sides', message: 'Enter an integer ≥ 1' });
|
||||
return;
|
||||
}
|
||||
const c = Number(count);
|
||||
if (!Number.isInteger(c) || c < 1) {
|
||||
setError({ field: 'count', message: 'Enter an integer ≥ 1' });
|
||||
return;
|
||||
}
|
||||
onSubmit({ sides: s, count: c });
|
||||
};
|
||||
const { sides, count, error, handleSidesChange, handleCountChange, handleSubmit } =
|
||||
useRollDieDialog({ isOpen, lastSides, lastCount, onSubmit });
|
||||
|
||||
return (
|
||||
<StyledDialog
|
||||
|
|
@ -91,12 +68,7 @@ function RollDieDialog({
|
|||
size="small"
|
||||
label="Sides"
|
||||
value={sides}
|
||||
onChange={(e) => {
|
||||
setSides(e.target.value);
|
||||
if (error) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => handleSidesChange(e.target.value)}
|
||||
error={error?.field === 'sides'}
|
||||
helperText={error?.field === 'sides' ? error.message : ''}
|
||||
slotProps={{ htmlInput: { 'aria-label': 'Sides', inputMode: 'numeric' } }}
|
||||
|
|
@ -107,12 +79,7 @@ function RollDieDialog({
|
|||
size="small"
|
||||
label="Count"
|
||||
value={count}
|
||||
onChange={(e) => {
|
||||
setCount(e.target.value);
|
||||
if (error) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => handleCountChange(e.target.value)}
|
||||
error={error?.field === 'count'}
|
||||
helperText={error?.field === 'count' ? error.message : ''}
|
||||
slotProps={{ htmlInput: { 'aria-label': 'Count', inputMode: 'numeric' } }}
|
||||
|
|
|
|||
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 CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import { useScryfallCard } from '@app/hooks';
|
||||
import { GameSelectors, useAppSelector } from '@app/store';
|
||||
import type { Data } from '@app/types';
|
||||
|
||||
import './ZoneViewDialog.css';
|
||||
import { useZoneViewDialog } from './useZoneViewDialog';
|
||||
|
||||
const EMPTY_CARDS: Data.ServerInfo_Card[] = [];
|
||||
import './ZoneViewDialog.css';
|
||||
|
||||
export interface ZoneViewDialogProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -19,19 +17,6 @@ export interface ZoneViewDialogProps {
|
|||
initialPosition?: { x: number; y: number };
|
||||
}
|
||||
|
||||
function zoneLabel(zoneName: string | undefined): string {
|
||||
switch (zoneName) {
|
||||
case 'grave': return 'Graveyard';
|
||||
case 'rfg': return 'Exile';
|
||||
case 'deck': return 'Library';
|
||||
case 'sb': return 'Sideboard';
|
||||
case 'stack': return 'Stack';
|
||||
case 'hand': return 'Hand';
|
||||
case 'table': return 'Battlefield';
|
||||
default: return zoneName ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function ZoneThumbnail({ card }: { card: Data.ServerInfo_Card }) {
|
||||
const { smallUrl } = useScryfallCard(card);
|
||||
return (
|
||||
|
|
@ -57,71 +42,8 @@ function ZoneViewDialog({
|
|||
handleClose,
|
||||
initialPosition = DEFAULT_POSITION,
|
||||
}: ZoneViewDialogProps) {
|
||||
const cards = useAppSelector((state) =>
|
||||
gameId != null && playerId != null && zoneName != null
|
||||
? GameSelectors.getCards(state, gameId, playerId, zoneName)
|
||||
: EMPTY_CARDS,
|
||||
);
|
||||
const zone = useAppSelector((state) =>
|
||||
gameId != null && playerId != null && zoneName != null
|
||||
? GameSelectors.getZone(state, gameId, playerId, zoneName)
|
||||
: undefined,
|
||||
);
|
||||
const playerName = useAppSelector((state) => {
|
||||
if (gameId == null || playerId == null) {
|
||||
return undefined;
|
||||
}
|
||||
return GameSelectors.getPlayer(state, gameId, playerId)?.properties.userInfo?.name;
|
||||
});
|
||||
|
||||
const count = zone?.cardCount ?? cards.length;
|
||||
const title = `${playerName ?? ''} ${zoneLabel(zoneName)} (${count})`.trim();
|
||||
|
||||
// initialPosition is a caller-provided spawn point; we only honor it on mount.
|
||||
// Later rerenders of the parent must not clobber a user's drag-positioned panel.
|
||||
const [position, setPosition] = useState(initialPosition);
|
||||
const dragStateRef = useRef<{
|
||||
pointerId: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
panelX: number;
|
||||
panelY: number;
|
||||
} | null>(null);
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
const target = e.currentTarget;
|
||||
target.setPointerCapture(e.pointerId);
|
||||
dragStateRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
originX: e.clientX,
|
||||
originY: e.clientY,
|
||||
panelX: position.x,
|
||||
panelY: position.y,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragStateRef.current;
|
||||
if (!drag || e.pointerId !== drag.pointerId) {
|
||||
return;
|
||||
}
|
||||
setPosition({
|
||||
x: drag.panelX + (e.clientX - drag.originX),
|
||||
y: drag.panelY + (e.clientY - drag.originY),
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragStateRef.current;
|
||||
if (!drag || e.pointerId !== drag.pointerId) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
dragStateRef.current = null;
|
||||
};
|
||||
const { cards, count, title, position, handlePointerDown, handlePointerMove, handlePointerUp } =
|
||||
useZoneViewDialog({ gameId, playerId, zoneName, initialPosition });
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
|
|
|
|||
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 Button from '@mui/material/Button';
|
||||
|
|
@ -9,76 +7,26 @@ import StepLabel from '@mui/material/StepLabel';
|
|||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
|
||||
import { InputField, VirtualList } from '@app/components';
|
||||
import { cardImporterService, CardDTO, SetDTO, TokenDTO } from '@app/services';
|
||||
|
||||
import { useCardImportForm } from './useCardImportForm';
|
||||
|
||||
import './CardImportForm.css';
|
||||
|
||||
const CardImportForm = ({ onSubmit: onClose }) => {
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [importedCards, setImportedCards] = useState([]);
|
||||
const [importedSets, setImportedSets] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
setError(null);
|
||||
}
|
||||
}, [loading])
|
||||
const {
|
||||
loading,
|
||||
activeStep,
|
||||
importedCards,
|
||||
importedSets,
|
||||
error,
|
||||
handleBack,
|
||||
handleCardDownload,
|
||||
handleCardSave,
|
||||
handleTokenDownload,
|
||||
} = useCardImportForm();
|
||||
|
||||
const steps = ['Imports sets', 'Save sets', 'Import tokens', 'Finished'];
|
||||
|
||||
const handleNext = () => {
|
||||
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setActiveStep((prevActiveStep) => prevActiveStep - 1);
|
||||
};
|
||||
|
||||
const handleCardDownload = ({ cardDownloadUrl }) => {
|
||||
setLoading(true);
|
||||
|
||||
cardImporterService.importCards(cardDownloadUrl)
|
||||
.then(({ cards, sets }) => {
|
||||
setImportedCards(cards);
|
||||
setImportedSets(sets);
|
||||
|
||||
handleNext();
|
||||
})
|
||||
.catch(({ message }) => setError(message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const handleCardSave = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await CardDTO.bulkAdd(importedCards);
|
||||
await SetDTO.bulkAdd(importedSets);
|
||||
|
||||
handleNext();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('Failed to save cards');
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleTokenDownload = ({ tokenDownloadUrl }) => {
|
||||
setLoading(true);
|
||||
|
||||
cardImporterService.importTokens(tokenDownloadUrl)
|
||||
.then(async tokens => {
|
||||
await TokenDTO.bulkAdd(tokens);
|
||||
handleNext();
|
||||
})
|
||||
.catch(({ message }) => setError(message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const getStepContent = (stepIndex) => {
|
||||
switch (stepIndex) {
|
||||
case 0: return (
|
||||
|
|
@ -175,14 +123,14 @@ const CardImportForm = ({ onSubmit: onClose }) => {
|
|||
</Stepper>
|
||||
|
||||
<div>
|
||||
{ getStepContent(activeStep) }
|
||||
{getStepContent(activeStep)}
|
||||
</div>
|
||||
|
||||
{ loading && (
|
||||
{loading && (
|
||||
<div className='loading'>
|
||||
<CircularProgress size={60} />
|
||||
</div>
|
||||
) }
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
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 { Form, Field, useFormState, FormApi } from 'react-final-form';
|
||||
import React from 'react';
|
||||
import { Form, Field, FormApi } from 'react-final-form';
|
||||
import { OnChange } from 'react-final-form-listeners';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -9,7 +9,8 @@ import FormControlLabel from '@mui/material/FormControlLabel';
|
|||
|
||||
import { CheckboxField, InputField, KnownHosts } from '@app/components';
|
||||
import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
|
||||
import { HostDTO } from '@app/services';
|
||||
|
||||
import { useLoginFormBody } from './useLoginForm';
|
||||
|
||||
import './LoginForm.css';
|
||||
|
||||
|
|
@ -34,61 +35,15 @@ const LoginFormBody = ({
|
|||
const PASSWORD_LABEL = t('Common.label.password');
|
||||
const STORED_PASSWORD_LABEL = `* ${t('LoginForm.label.savedPassword')} *`;
|
||||
|
||||
const settings = useSettings();
|
||||
const hosts = useKnownHosts();
|
||||
const { values } = useFormState();
|
||||
|
||||
const selectedHost = hosts.status === LoadingState.READY ? hosts.value?.selectedHost : undefined;
|
||||
|
||||
const [useStoredPasswordLabel, setUseStoredPasswordLabel] = useState(false);
|
||||
const [storedHashInvalidated, setStoredHashInvalidated] = useState(false);
|
||||
|
||||
const canUseStoredPassword = (remember: boolean, password: string | undefined) =>
|
||||
Boolean(remember && selectedHost?.hashedPassword && !password && !storedHashInvalidated);
|
||||
|
||||
const togglePasswordLabel = (on: boolean) => setUseStoredPasswordLabel(on);
|
||||
|
||||
// @critical Host-sync must not touch autoConnect — app-level setting, not per-host.
|
||||
const onSelectedHostChange = (host: HostDTO | undefined) => {
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
form.change('userName', host.userName ?? '');
|
||||
form.change('password', '');
|
||||
form.change('remember', Boolean(host.remember));
|
||||
setStoredHashInvalidated(false);
|
||||
togglePasswordLabel(Boolean(host.remember && host.hashedPassword));
|
||||
};
|
||||
|
||||
const onUserNameChange = (userName: string | undefined) => {
|
||||
const fieldChanged = selectedHost?.userName?.toLowerCase() !== userName?.toLowerCase();
|
||||
if (canUseStoredPassword(values.remember, values.password) && fieldChanged) {
|
||||
setStoredHashInvalidated(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onRememberChange = (checked: boolean) => {
|
||||
// @critical Writes form-only, never to persisted setting — "remember" toggle isn't a preference edit.
|
||||
if (!checked && values.autoConnect) {
|
||||
form.change('autoConnect', false);
|
||||
}
|
||||
|
||||
togglePasswordLabel(canUseStoredPassword(checked, values.password));
|
||||
};
|
||||
|
||||
// @critical Only persist-path for autoConnect; wired to native onChange, not <OnChange>,
|
||||
// to avoid leaking form.change() writes into Dexie.
|
||||
const onUserToggleAutoConnect = (checked: boolean, fieldOnChange: (v: boolean) => void) => {
|
||||
fieldOnChange(checked);
|
||||
|
||||
if (settings.status === LoadingState.READY) {
|
||||
void settings.update({ autoConnect: checked });
|
||||
}
|
||||
|
||||
if (checked && !values.remember) {
|
||||
form.change('remember', true);
|
||||
}
|
||||
};
|
||||
const {
|
||||
useStoredPasswordLabel,
|
||||
setUseStoredPasswordLabel,
|
||||
onSelectedHostChange,
|
||||
onUserNameChange,
|
||||
onRememberChange,
|
||||
onUserToggleAutoConnect,
|
||||
passwordFieldBlur,
|
||||
} = useLoginFormBody(form);
|
||||
|
||||
return (
|
||||
<form className="loginForm" onSubmit={handleSubmit}>
|
||||
|
|
@ -106,9 +61,7 @@ const LoginFormBody = ({
|
|||
<Field
|
||||
label={useStoredPasswordLabel ? STORED_PASSWORD_LABEL : PASSWORD_LABEL}
|
||||
onFocus={() => setUseStoredPasswordLabel(false)}
|
||||
onBlur={() =>
|
||||
togglePasswordLabel(canUseStoredPassword(values.remember, values.password))
|
||||
}
|
||||
onBlur={passwordFieldBlur}
|
||||
name="password"
|
||||
type="password"
|
||||
component={InputField}
|
||||
|
|
|
|||
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 { useSelector } from 'react-redux';
|
||||
import { useEffect } from 'react';
|
||||
import { Form, Field } from 'react-final-form';
|
||||
import { OnChange } from 'react-final-form-listeners';
|
||||
import setFieldTouched from 'final-form-set-field-touched';
|
||||
|
|
@ -9,45 +8,25 @@ import Button from '@mui/material/Button';
|
|||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import { CountryDropdown, InputField, KnownHosts } from '@app/components';
|
||||
import { useReduxEffect } from '@app/hooks';
|
||||
import { ServerDispatch, ServerSelectors, ServerTypes } from '@app/store';
|
||||
import { ServerDispatch } from '@app/store';
|
||||
|
||||
import { useRegisterForm } from './useRegisterForm';
|
||||
|
||||
import './RegisterForm.css';
|
||||
import { useToast } from '@app/components';
|
||||
|
||||
const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [emailRequired, setEmailRequired] = useState(false);
|
||||
const [emailError, setEmailError] = useState(null);
|
||||
const [passwordError, setPasswordError] = useState(null);
|
||||
const [userNameError, setUserNameError] = useState(null);
|
||||
const error = useSelector(ServerSelectors.getRegistrationError);
|
||||
const { openToast } = useToast({ key: 'registration-success', children: t('RegisterForm.toast.registerSuccess') })
|
||||
|
||||
const onHostChange = () => setEmailRequired(false);
|
||||
const onEmailChange = () => emailError && setEmailError(null);
|
||||
const onPasswordChange = () => passwordError && setPasswordError(null);
|
||||
const onUserNameChange = () => userNameError && setUserNameError(null);
|
||||
|
||||
useReduxEffect(() => {
|
||||
setEmailRequired(true);
|
||||
}, ServerTypes.REGISTRATION_REQUIRES_EMAIL);
|
||||
|
||||
useReduxEffect(() => {
|
||||
openToast()
|
||||
}, ServerTypes.REGISTRATION_SUCCESS);
|
||||
|
||||
useReduxEffect(({ payload: { error } }) => {
|
||||
setEmailError(error);
|
||||
}, ServerTypes.REGISTRATION_EMAIL_ERROR);
|
||||
|
||||
useReduxEffect(({ payload: { error } }) => {
|
||||
setPasswordError(error);
|
||||
}, ServerTypes.REGISTRATION_PASSWORD_ERROR);
|
||||
|
||||
useReduxEffect(({ payload: { error } }) => {
|
||||
setUserNameError(error);
|
||||
}, ServerTypes.REGISTRATION_USERNAME_ERROR);
|
||||
const {
|
||||
emailRequired,
|
||||
emailError,
|
||||
passwordError,
|
||||
userNameError,
|
||||
error,
|
||||
onHostChange,
|
||||
onEmailChange,
|
||||
onPasswordChange,
|
||||
onUserNameChange,
|
||||
} = useRegisterForm();
|
||||
|
||||
const handleOnSubmit = ({ userName, email, realName, ...values }) => {
|
||||
ServerDispatch.clearRegistrationErrors();
|
||||
|
|
@ -57,7 +36,7 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
|
|||
realName = realName?.trim();
|
||||
|
||||
onSubmit({ userName, email, realName, ...values });
|
||||
}
|
||||
};
|
||||
|
||||
const validate = values => {
|
||||
const errors: any = {};
|
||||
|
|
@ -93,7 +72,7 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
|
|||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleOnSubmit} validate={validate} mutators={{ setFieldTouched }}>
|
||||
|
|
@ -149,12 +128,12 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
|
|||
<Field label={t('Common.label.country')} name="country" component={CountryDropdown} />
|
||||
</div>
|
||||
<Button className="RegisterForm-submit tall" color="primary" variant="contained" type="submit">
|
||||
{ t('RegisterForm.label.register') }
|
||||
{t('RegisterForm.label.register')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{ error && (
|
||||
{error && (
|
||||
<div className="RegisterForm-item">
|
||||
<Typography color="error">{error}</Typography>
|
||||
</div>
|
||||
|
|
|
|||
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 { OnChange } from 'react-final-form-listeners';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -8,23 +6,14 @@ import Button from '@mui/material/Button';
|
|||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import { InputField, KnownHosts } from '@app/components';
|
||||
import { useReduxEffect } from '@app/hooks';
|
||||
import { ServerTypes } from '@app/store';
|
||||
|
||||
import { useRequestPasswordResetForm } from './useRequestPasswordResetForm';
|
||||
|
||||
import './RequestPasswordResetForm.css';
|
||||
|
||||
const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
|
||||
const [errorMessage, setErrorMessage] = useState(false);
|
||||
const [isMFA, setIsMFA] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useReduxEffect(() => {
|
||||
setErrorMessage(true);
|
||||
}, ServerTypes.RESET_PASSWORD_FAILED, []);
|
||||
|
||||
useReduxEffect(() => {
|
||||
setIsMFA(true);
|
||||
}, ServerTypes.RESET_PASSWORD_CHALLENGE, []);
|
||||
const { errorMessage, setErrorMessage, isMFA, setIsMFA } = useRequestPasswordResetForm();
|
||||
|
||||
const handleOnSubmit = ({ userName, email, ...values }) => {
|
||||
setErrorMessage(false);
|
||||
|
|
@ -33,7 +22,7 @@ const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
|
|||
email = email?.trim();
|
||||
|
||||
onSubmit({ userName, email, ...values });
|
||||
}
|
||||
};
|
||||
|
||||
const validate = values => {
|
||||
const errors: any = {};
|
||||
|
|
@ -57,7 +46,7 @@ const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
|
|||
const onHostChange: any = ({ userName }) => {
|
||||
form.change('userName', userName);
|
||||
setIsMFA(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="RequestPasswordResetForm" onSubmit={handleSubmit}>
|
||||
|
|
@ -68,7 +57,7 @@ const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
|
|||
{isMFA ? (
|
||||
<div className="RequestPasswordResetForm-item">
|
||||
<Field label={t('Common.label.email')} name="email" type="email" component={InputField} autoComplete="email" />
|
||||
<div>{ t('RequestPasswordResetForm.mfaEnabled') }</div>
|
||||
<div>{t('RequestPasswordResetForm.mfaEnabled')}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="RequestPasswordResetForm-item selectedHost">
|
||||
|
|
@ -78,18 +67,18 @@ const RequestPasswordResetForm = ({ onSubmit, skipTokenRequest }) => {
|
|||
|
||||
{errorMessage && (
|
||||
<div className="RequestPasswordResetForm-item">
|
||||
<Typography color="error">{ t('RequestPasswordResetForm.error') }</Typography>
|
||||
<Typography color="error">{t('RequestPasswordResetForm.error')}</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button className="RequestPasswordResetForm-submit rounded tall" color="primary" variant="contained" type="submit">
|
||||
{ t('RequestPasswordResetForm.request') }
|
||||
{t('RequestPasswordResetForm.request')}
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<Button color="primary" onClick={() => skipTokenRequest(form.getState().values.userName)}>
|
||||
{ t('RequestPasswordResetForm.skipRequest') }
|
||||
{t('RequestPasswordResetForm.skipRequest')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -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 React, { useEffect, useState } from 'react';
|
||||
import { Form, Field } from 'react-final-form'
|
||||
import { Form, Field } from 'react-final-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Button from '@mui/material/Button';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import { InputField, KnownHosts } from '@app/components';
|
||||
import { useReduxEffect } from '@app/hooks';
|
||||
import { ServerTypes } from '@app/store';
|
||||
|
||||
import { useResetPasswordForm } from './useResetPasswordForm';
|
||||
|
||||
import './ResetPasswordForm.css';
|
||||
|
||||
const ResetPasswordForm = ({ onSubmit, userName }) => {
|
||||
const [errorMessage, setErrorMessage] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useReduxEffect(() => {
|
||||
setErrorMessage(true);
|
||||
}, ServerTypes.RESET_PASSWORD_FAILED, []);
|
||||
const { errorMessage } = useResetPasswordForm();
|
||||
|
||||
const validate = values => {
|
||||
const errors: any = {};
|
||||
|
|
@ -53,7 +47,7 @@ const ResetPasswordForm = ({ onSubmit, userName }) => {
|
|||
token = token?.trim();
|
||||
|
||||
onSubmit({ userName, token, ...values });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleOnSubmit} validate={validate} initialValues={{ userName }}>
|
||||
|
|
@ -96,12 +90,12 @@ const ResetPasswordForm = ({ onSubmit, userName }) => {
|
|||
|
||||
{errorMessage && (
|
||||
<div className='ResetPasswordForm-item'>
|
||||
<Typography color="error">{ t('ResetPasswordForm.error') }</Typography>
|
||||
<Typography color="error">{t('ResetPasswordForm.error')}</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button className='ResetPasswordForm-submit rounded tall' color='primary' variant='contained' type='submit'>
|
||||
{ t('ResetPasswordForm.label.reset') }
|
||||
{t('ResetPasswordForm.label.reset')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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