mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-06-10 00:04:48 -07:00
use component hooks
This commit is contained in:
parent
515dff6d7b
commit
3aa8c654cc
81 changed files with 5203 additions and 3173 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue