From 5ff0eba45952ea3fc941cde28fa6e9e4fda3c5de Mon Sep 17 00:00:00 2001 From: jraya106 Date: Thu, 14 May 2026 19:53:54 +0100 Subject: [PATCH 01/26] Trigger Railway redeploy From c7a591252938c257cd3d73b59838b3d306b9bc9a Mon Sep 17 00:00:00 2001 From: jraya106 Date: Fri, 15 May 2026 12:18:44 +0100 Subject: [PATCH 02/26] Add board arrows and hosted fallback clarity --- apps/web/src/App.tsx | 50 ++++++++++++- apps/web/src/BoardCanvas.tsx | 137 +++++++++++++++++++++++++++++++++-- 2 files changed, 179 insertions(+), 8 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7f018d8..8b21001 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -8,7 +8,7 @@ import { makeBoard, cloneBoard, findKing, isAttacked, inB, legalMoves, gameStatu import { CARD_POOL, drawRandomCard, incrementCardSeq } from './cardPool'; import { RARITY_STYLE, RARITY_WEIGHTS, OPP, FILES, RANKS, SQ, MAX_HAND_SIZE, CLOCK_START, ABORT_SECS, DRAW_FROM, DRAW_EVERY, INITIAL_DEAL_ROUND, PIECE_VALUE, UPGRADE, DOWNGRADE, TARGETING_CARDS, CARD_TARGET_MESSAGES } from './constants'; import { GLOBAL_STYLES } from './styles'; -import { BoardCanvas, type TransformAnim, type SniperAnim, type TeleportAnim, type JumpAnim, type SacrificeAnim, type MindControlAnim, type FuseAnim } from './BoardCanvas'; +import { BoardCanvas, type TransformAnim, type SniperAnim, type TeleportAnim, type JumpAnim, type SacrificeAnim, type MindControlAnim, type FuseAnim, type BoardArrow } from './BoardCanvas'; import { CardAnimOverlay } from './CardAnimOverlay'; import CardsPage from './CardsPage'; import HistoryPage from './HistoryPage'; @@ -196,6 +196,11 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH httpBaseUrl: runtimeConfig?.matchServiceHttpBase, wsBaseUrl: runtimeConfig?.matchServiceWsBase, } satisfies MatchServiceRuntimeConfig); + const hostedRuntime = React.useMemo(() => { + if (typeof window === 'undefined') return false; + const hostname = window.location.hostname.toLowerCase(); + return hostname !== 'localhost' && hostname !== '127.0.0.1'; + }, []); const [activePage, setActivePage] = React.useState('Play'); const [communityFocusGuestId, setCommunityFocusGuestId] = React.useState(null); const [historyFocusMatchId, setHistoryFocusMatchId] = React.useState(null); @@ -230,6 +235,7 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH const [movHist, setMovHist] = React.useState<{ n: string; w?: string; b?: string }[]>([]); const [snapshots, setSnapshots] = React.useState([]); const [reviewIdx, setReviewIdx] = React.useState(-1); + const [analysisArrows, setAnalysisArrows] = React.useState([]); // Card state const [whiteHand, setWhiteHand] = React.useState([]); @@ -1356,6 +1362,7 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH setBlackHand([]); lastDrawRound.current = 0; setDealPhase('done'); + setAnalysisArrows([]); if (gameKey === 0) startAbortCountdown(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [gameKey]); @@ -1367,6 +1374,7 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH React.useEffect(() => { writeStoredActiveMatchId(authoritativeMatchId); + setAnalysisArrows([]); }, [authoritativeMatchId]); React.useEffect(() => { @@ -3787,6 +3795,26 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH return true; }, []); + const toggleAnalysisArrow = React.useCallback((from: Sq, to: Sq) => { + setAnalysisArrows(current => { + const existingIndex = current.findIndex( + arrow => + arrow.from.row === from.row && + arrow.from.col === from.col && + arrow.to.row === to.row && + arrow.to.col === to.col, + ); + if (existingIndex >= 0) { + return current.filter((_, index) => index !== existingIndex); + } + return [...current, { from, to }]; + }); + }, []); + + const clearAnalysisArrows = React.useCallback(() => { + setAnalysisArrows(current => (current.length ? [] : current)); + }, []); + const clickSq = React.useCallback((r: number, c: number) => { if (cardPending) { handleCardClick(r, c); return; } if (isReviewing || over || promo) return; @@ -4761,6 +4789,9 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH fogZones={fogZones} viewerColor={turn} invisibleUnder={ghostPiece} + analysisArrows={analysisArrows} + onToggleAnalysisArrow={toggleAnalysisArrow} + onClearAnalysisArrows={clearAnalysisArrows} /> {/* Promotion overlay */} @@ -4856,13 +4887,26 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH borderRadius:'12px', flexShrink:0, }}>
-
- {authoritativeLive ? 'Authoritative Sync Live' : 'Local Fallback Active'} +
+ {authoritativeLive ? 'Online Match Live' : hostedRuntime ? 'Hosted Fallback Active' : 'Local Fallback Active'}
{authoritativeMatchIdRef.current ? `match ${authoritativeMatchIdRef.current.slice(-6)}` : 'no match'}
+ {hostedRuntime && !authoritativeLive && ( +
+
+ Backend sync is unavailable, so this session is running browser-side fallback instead of a real online match. +
+ +
+ )}
Round
diff --git a/apps/web/src/BoardCanvas.tsx b/apps/web/src/BoardCanvas.tsx index f808efc..527fbce 100644 --- a/apps/web/src/BoardCanvas.tsx +++ b/apps/web/src/BoardCanvas.tsx @@ -63,6 +63,12 @@ export interface ReverseAnim { startTime: number; } +export interface BoardArrow { + from: Sq; + to: Sq; + color?: string; +} + export interface BoardCanvasProps { board: Board; turn: PieceColor; @@ -101,6 +107,9 @@ export interface BoardCanvasProps { fogZones: { centerRow: number; centerCol: number; ownerColor: PieceColor }[]; viewerColor: PieceColor; invisibleUnder?: { row: number; col: number; piece: Piece; ownerColor: PieceColor } | null; + analysisArrows: BoardArrow[]; + onToggleAnalysisArrow: (from: Sq, to: Sq) => void; + onClearAnalysisArrows: () => void; } // Preload all piece images once @@ -173,6 +182,74 @@ const easeIn = (t: number) => t * t * t; const easeInOut = (t: number) => t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2,3)/2; const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); const lerp = (a: number, b: number, t: number) => a + (b - a) * t; +const ANALYSIS_ARROW_COLOR = 'rgba(244,196,48,0.9)'; + +function drawBoardArrow( + ctx: CanvasRenderingContext2D, + from: Sq, + to: Sq, + color = ANALYSIS_ARROW_COLOR, + options: { alpha?: number; preview?: boolean } = {}, +) { + const alpha = options.alpha ?? 0.88; + const fromX = from.col * SQ + SQ / 2; + const fromY = (7 - from.row) * SQ + SQ / 2; + const toX = to.col * SQ + SQ / 2; + const toY = (7 - to.row) * SQ + SQ / 2; + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.shadowColor = options.preview ? 'rgba(255,235,140,0.45)' : 'rgba(255,220,120,0.65)'; + ctx.shadowBlur = options.preview ? 8 : 10; + + if (from.row === to.row && from.col === to.col) { + const radius = SQ * 0.28; + ctx.lineWidth = 6; + ctx.beginPath(); + ctx.arc(fromX, fromY, radius, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = alpha * 0.2; + ctx.beginPath(); + ctx.arc(fromX, fromY, radius * 0.72, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + return; + } + + const dx = toX - fromX; + const dy = toY - fromY; + const length = Math.hypot(dx, dy); + const angle = Math.atan2(dy, dx); + const shaftWidth = 10; + const headLength = Math.min(SQ * 0.45, Math.max(SQ * 0.25, length * 0.32)); + const headHalfWidth = shaftWidth * 1.35; + const shaftEnd = Math.max(0, length - headLength); + + ctx.translate(fromX, fromY); + ctx.rotate(angle); + + ctx.lineWidth = shaftWidth; + if (options.preview) { + ctx.setLineDash([16, 10]); + } + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(shaftEnd, 0); + ctx.stroke(); + ctx.setLineDash([]); + + ctx.beginPath(); + ctx.moveTo(length, 0); + ctx.lineTo(shaftEnd, -headHalfWidth); + ctx.lineTo(shaftEnd, headHalfWidth); + ctx.closePath(); + ctx.fill(); + ctx.restore(); +} // ─── Teleport Animation ─────────────────────────────────────────────────────── @@ -1921,7 +1998,7 @@ export const BoardCanvas = React.memo(function BoardCanvas(props: BoardCanvasPro cardHighlight, doubleMoveHighlight, bombPieces, bombExploding, lavaSquares, lavaExploding, swapAnim, isReviewing, reviewBoard, cardPending, onClick, onDragStart, onDrop, doubleMove, transformAnim, - sniperAnim, teleportAnim, jumpAnim, reverseAnim, sacrificeAnim, sacrificeSelectedSquares, mindControlAnim, mindControlTargetSquare, fuseAnim, fuseSelectedSq, fogZones, viewerColor, invisibleUnder, + sniperAnim, teleportAnim, jumpAnim, reverseAnim, sacrificeAnim, sacrificeSelectedSquares, mindControlAnim, mindControlTargetSquare, fuseAnim, fuseSelectedSq, fogZones, viewerColor, invisibleUnder, analysisArrows, onToggleAnalysisArrow, onClearAnalysisArrows, } = props; const canvasRef = React.useRef(null); @@ -2289,6 +2366,13 @@ export const BoardCanvas = React.memo(function BoardCanvas(props: BoardCanvasPro } ctx.textBaseline = 'alphabetic'; + for (const arrow of analysisArrows) { + drawBoardArrow(ctx, arrow.from, arrow.to, arrow.color); + } + if (annotationStartRef.current && annotationTargetRef.current) { + drawBoardArrow(ctx, annotationStartRef.current, annotationTargetRef.current, ANALYSIS_ARROW_COLOR, { alpha: 0.6, preview: true }); + } + // ── Draw ghost (invisible) piece and any piece on same square ──────── if (invisibleUnder) { const { row: ur, col: uc, piece: up } = invisibleUnder; @@ -3366,15 +3450,21 @@ export const BoardCanvas = React.memo(function BoardCanvas(props: BoardCanvasPro displayBoard, sel, hints, lm, check, kingPos, cardHighlight, doubleMoveHighlight, bombPieces, bombExploding, lavaSquares, lavaExploding, isReviewing, doubleMove, transformAnim, sniperAnim, reverseAnim, sacrificeAnim, sacrificeSelectedSquares, mindControlAnim, mindControlTargetSquare, fuseAnim, fuseSelectedSq, - fogZones, viewerColor, + fogZones, viewerColor, analysisArrows, ]); const [localDrag, setLocalDrag] = React.useState(null); const [localDragPos, setLocalDragPos] = React.useState<{ x: number; y: number } | null>(null); + const [annotationStart, setAnnotationStart] = React.useState(null); + const [annotationTarget, setAnnotationTarget] = React.useState(null); const localDragRef = React.useRef(null); const localDragPosRef = React.useRef<{ x: number; y: number } | null>(null); + const annotationStartRef = React.useRef(null); + const annotationTargetRef = React.useRef(null); React.useEffect(() => { localDragRef.current = localDrag; }, [localDrag]); React.useEffect(() => { localDragPosRef.current = localDragPos; }, [localDragPos]); + React.useEffect(() => { annotationStartRef.current = annotationStart; }, [annotationStart]); + React.useEffect(() => { annotationTargetRef.current = annotationTarget; }, [annotationTarget]); const getSquare = (e: React.MouseEvent): Sq => { const rect = canvasRef.current!.getBoundingClientRect(); @@ -3383,9 +3473,17 @@ export const BoardCanvas = React.memo(function BoardCanvas(props: BoardCanvasPro }; const handleMouseDown = (e: React.MouseEvent) => { - if (cardPending || isReviewing) return; const sq = getSquare(e); if (!sq || sq.row < 0 || sq.row > 7 || sq.col < 0 || sq.col > 7) return; + if (e.button === 2) { + e.preventDefault(); + setAnnotationStart(sq); + setAnnotationTarget(sq); + return; + } + if (e.button !== 0) return; + if (cardPending || isReviewing) return; + onClearAnalysisArrows(); const p = displayBoard[sq.row]?.[sq.col]; if (p?.color === turn) { setLocalDrag(sq); @@ -3395,12 +3493,28 @@ export const BoardCanvas = React.memo(function BoardCanvas(props: BoardCanvasPro }; const handleMouseMove = (e: React.MouseEvent) => { + if (annotationStart) { + const sq = getSquare(e); + if (sq && sq.row >= 0 && sq.row <= 7 && sq.col >= 0 && sq.col <= 7) { + setAnnotationTarget(sq); + } + return; + } if (!localDrag) return; const rect = canvasRef.current!.getBoundingClientRect(); setLocalDragPos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); }; const handleMouseUp = (e: React.MouseEvent) => { + if (annotationStart) { + const sq = getSquare(e); + if (sq && sq.row >= 0 && sq.row <= 7 && sq.col >= 0 && sq.col <= 7) { + onToggleAnalysisArrow(annotationStart, sq); + } + setAnnotationStart(null); + setAnnotationTarget(null); + return; + } if (localDrag) { const sq = getSquare(e); if (sq && sq.row >= 0 && sq.row <= 7 && sq.col >= 0 && sq.col <= 7) { @@ -3413,12 +3527,24 @@ export const BoardCanvas = React.memo(function BoardCanvas(props: BoardCanvasPro const handleClick = (e: React.MouseEvent) => { if (localDrag) return; + if (annotationStart) return; const sq = getSquare(e); if (sq && sq.row >= 0 && sq.row <= 7 && sq.col >= 0 && sq.col <= 7) { onClick(sq.row, sq.col); } }; + const handleMouseLeave = () => { + if (annotationStartRef.current) { + setAnnotationStart(null); + setAnnotationTarget(null); + } + if (localDragRef.current) { + setLocalDrag(null); + setLocalDragPos(null); + } + }; + return ( e.preventDefault()} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onClick={handleClick} - onMouseLeave={handleMouseUp} + onMouseLeave={handleMouseLeave} /> ); }); From 69de7986cc91db6543338317e88aa75ad17e182b Mon Sep 17 00:00:00 2001 From: jraya106 Date: Fri, 15 May 2026 13:22:58 +0100 Subject: [PATCH 03/26] Clarify hosted practice and single-player queue --- apps/web/src/App.tsx | 27 ++++++++++++++++-------- apps/web/src/QueuePage.tsx | 42 +++++++++++++++++++++++++++----------- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8b21001..4ed4ce6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -201,7 +201,11 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH const hostname = window.location.hostname.toLowerCase(); return hostname !== 'localhost' && hostname !== '127.0.0.1'; }, []); - const [activePage, setActivePage] = React.useState('Play'); + const [activePage, setActivePage] = React.useState(() => { + if (typeof window === 'undefined') return 'Play'; + const hostname = window.location.hostname.toLowerCase(); + return hostname !== 'localhost' && hostname !== '127.0.0.1' ? 'Queue' : 'Play'; + }); const [communityFocusGuestId, setCommunityFocusGuestId] = React.useState(null); const [historyFocusMatchId, setHistoryFocusMatchId] = React.useState(null); const [historyFocusGuestId, setHistoryFocusGuestId] = React.useState(null); @@ -4360,18 +4364,18 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH CardChess
- {['Play','Queue','History','Cards','Rankings','Community','Status','Account'].map((label, i) => ( - ))}
@@ -4693,6 +4697,11 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH {/* ── Board column ── */}
+ {hostedRuntime && ( +
+ Practice board: for real online opponents, use the Queue tab. +
+ )} {cardPending && (
🃏 {cardMsg}  ✕ cancel diff --git a/apps/web/src/QueuePage.tsx b/apps/web/src/QueuePage.tsx index 8a3351a..a0d74d7 100644 --- a/apps/web/src/QueuePage.tsx +++ b/apps/web/src/QueuePage.tsx @@ -105,6 +105,11 @@ function formatDateTime(value: string): string { } export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps): React.ReactElement { + const hostedRuntime = React.useMemo(() => { + if (typeof window === 'undefined') return false; + const hostname = window.location.hostname.toLowerCase(); + return hostname !== 'localhost' && hostname !== '127.0.0.1'; + }, []); const [queue, setQueue] = React.useState(() => readStoredQueueSelection()); const [whiteTicket, setWhiteTicket] = React.useState(null); const [blackTicket, setBlackTicket] = React.useState(null); @@ -196,6 +201,14 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps writeStoredTicketRef('black', blackTicket); }, [blackTicket]); + React.useEffect(() => { + if (!hostedRuntime) { + return; + } + clearStoredTicketRef('black'); + setBlackTicket(null); + }, [hostedRuntime]); + React.useEffect(() => { if (restoringTickets) { return; @@ -286,13 +299,13 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps ...existingRoomMeta, queue, whiteGuestId: existingRoomMeta?.whiteGuestId ?? whiteProfile?.guestId, - blackGuestId: existingRoomMeta?.blackGuestId ?? blackProfile?.guestId, + blackGuestId: existingRoomMeta?.blackGuestId ?? (hostedRuntime ? undefined : blackProfile?.guestId), whiteAccountId: existingRoomMeta?.whiteAccountId ?? readStoredAccountId('white') ?? undefined, - blackAccountId: existingRoomMeta?.blackAccountId ?? readStoredAccountId('black') ?? undefined, + blackAccountId: existingRoomMeta?.blackAccountId ?? (hostedRuntime ? undefined : readStoredAccountId('black') ?? undefined), whiteName: existingRoomMeta?.whiteName ?? whiteProfile?.displayName, - blackName: existingRoomMeta?.blackName ?? blackProfile?.displayName, + blackName: existingRoomMeta?.blackName ?? (hostedRuntime ? undefined : blackProfile?.displayName), whitePlayerSecret: resolveSeatSecret(existingRoomMeta?.whitePlayerSecret, readStoredGuestSessionSecret('white')), - blackPlayerSecret: resolveSeatSecret(existingRoomMeta?.blackPlayerSecret, readStoredGuestSessionSecret('black')), + blackPlayerSecret: resolveSeatSecret(existingRoomMeta?.blackPlayerSecret, hostedRuntime ? null : readStoredGuestSessionSecret('black')), }; writeStoredRoomMeta(ticket.assignedRoom, roomMeta); await ensureMatch({ @@ -309,19 +322,22 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps blackPlayerSecret: roomMeta.blackPlayerSecret, }); clearStoredTicketRef('white'); - clearStoredTicketRef('black'); + if (!hostedRuntime) { + clearStoredTicketRef('black'); + } window.location.href = `/?match=${encodeURIComponent(ticket.assignedRoom)}`; } catch (err) { setError(err instanceof Error ? err.message : 'Failed to open matched room.'); } finally { setLoading(false); } - }, [queue, whiteProfile, blackProfile]); + }, [queue, whiteProfile, blackProfile, hostedRuntime]); const queueCard = ( side: 'white' | 'black', profile: GuestProfile | null, - ticket: QueueTicket | null + ticket: QueueTicket | null, + title?: string, ): React.ReactElement => (
-
{profile?.displayName ?? 'Loading...'}
+
{title ?? profile?.displayName ?? 'Loading...'}
♟ {profile?.rating ?? 1200}
Assigned room: {ticket.assignedRoom}
} ) : ( -
Not in queue yet. Join the selected queue to create a local matchmaking ticket.
+
{hostedRuntime ? 'Not in queue yet. Join the selected queue to wait for another online player.' : 'Not in queue yet. Join the selected queue to create a local matchmaking ticket.'}
)}
@@ -443,7 +459,9 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps
Queue Control
- Local platform queue tickets with simple auto-match when a second player joins. + {hostedRuntime + ? 'Online queue control for this browser session. One browser now behaves like one player by default.' + : 'Local platform queue tickets with simple auto-match when a second player joins.'}
{restoringTickets && (
@@ -479,8 +497,8 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps
- {queueCard('white', whiteProfile, whiteTicket)} - {queueCard('black', blackProfile, blackTicket)} + {queueCard('white', whiteProfile, whiteTicket, hostedRuntime ? 'Your player' : undefined)} + {!hostedRuntime && queueCard('black', blackProfile, blackTicket)} {error && (
Date: Fri, 15 May 2026 13:32:58 +0100 Subject: [PATCH 04/26] Fix hosted hydration regression --- apps/web/src/App.tsx | 22 ++++++++++++---------- apps/web/src/QueuePage.tsx | 14 +++++++++----- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4ed4ce6..5fa11f6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -196,16 +196,8 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH httpBaseUrl: runtimeConfig?.matchServiceHttpBase, wsBaseUrl: runtimeConfig?.matchServiceWsBase, } satisfies MatchServiceRuntimeConfig); - const hostedRuntime = React.useMemo(() => { - if (typeof window === 'undefined') return false; - const hostname = window.location.hostname.toLowerCase(); - return hostname !== 'localhost' && hostname !== '127.0.0.1'; - }, []); - const [activePage, setActivePage] = React.useState(() => { - if (typeof window === 'undefined') return 'Play'; - const hostname = window.location.hostname.toLowerCase(); - return hostname !== 'localhost' && hostname !== '127.0.0.1' ? 'Queue' : 'Play'; - }); + const [hostedRuntime, setHostedRuntime] = React.useState(false); + const [activePage, setActivePage] = React.useState('Play'); const [communityFocusGuestId, setCommunityFocusGuestId] = React.useState(null); const [historyFocusMatchId, setHistoryFocusMatchId] = React.useState(null); const [historyFocusGuestId, setHistoryFocusGuestId] = React.useState(null); @@ -551,6 +543,16 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH }); }, []); + React.useEffect(() => { + if (typeof window === 'undefined') return; + const hostname = window.location.hostname.toLowerCase(); + const nextHosted = hostname !== 'localhost' && hostname !== '127.0.0.1'; + setHostedRuntime(nextHosted); + if (nextHosted) { + setActivePage(current => current === 'Play' ? 'Queue' : current); + } + }, []); + React.useEffect(() => { if (typeof window === 'undefined') return; requestedMatchIdRef.current = new URLSearchParams(window.location.search).get('match') ?? readStoredActiveMatchId(); diff --git a/apps/web/src/QueuePage.tsx b/apps/web/src/QueuePage.tsx index a0d74d7..26bf66a 100644 --- a/apps/web/src/QueuePage.tsx +++ b/apps/web/src/QueuePage.tsx @@ -105,11 +105,7 @@ function formatDateTime(value: string): string { } export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps): React.ReactElement { - const hostedRuntime = React.useMemo(() => { - if (typeof window === 'undefined') return false; - const hostname = window.location.hostname.toLowerCase(); - return hostname !== 'localhost' && hostname !== '127.0.0.1'; - }, []); + const [hostedRuntime, setHostedRuntime] = React.useState(false); const [queue, setQueue] = React.useState(() => readStoredQueueSelection()); const [whiteTicket, setWhiteTicket] = React.useState(null); const [blackTicket, setBlackTicket] = React.useState(null); @@ -123,6 +119,14 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps setQueueTickets(tickets); }, []); + React.useEffect(() => { + if (typeof window === 'undefined') { + return; + } + const hostname = window.location.hostname.toLowerCase(); + setHostedRuntime(hostname !== 'localhost' && hostname !== '127.0.0.1'); + }, []); + React.useEffect(() => { void refreshQueue(queue).catch(err => { setError(err instanceof Error ? err.message : 'Failed to load queue state.'); From a21213329febcae6ea015ee2568d9c4c20d79be9 Mon Sep 17 00:00:00 2001 From: jraya106 Date: Fri, 15 May 2026 13:41:50 +0100 Subject: [PATCH 05/26] Fallback to polling when match WebSocket is missing --- apps/web/src/lib/match-service.ts | 63 +++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/match-service.ts b/apps/web/src/lib/match-service.ts index b3e86ba..3d5b2d7 100644 --- a/apps/web/src/lib/match-service.ts +++ b/apps/web/src/lib/match-service.ts @@ -154,6 +154,7 @@ export function connectToMatchStream( ): () => void { let socket: WebSocket | null = null; let reconnectTimer: number | null = null; + let pollTimer: number | null = null; let disposed = false; let reconnectAttempt = 0; @@ -164,6 +165,39 @@ export function connectToMatchStream( } }; + const clearPollTimer = () => { + if (pollTimer !== null) { + window.clearTimeout(pollTimer); + pollTimer = null; + } + }; + + const schedulePoll = (delay = 2000) => { + if (disposed) { + return; + } + clearPollTimer(); + pollTimer = window.setTimeout(async () => { + pollTimer = null; + if (disposed) { + return; + } + try { + const snapshot = await fetchMatch(matchId); + if (!disposed) { + handlers.onSnapshot(snapshot); + handlers.onStatusChange?.('connected'); + } + } catch { + if (!disposed) { + handlers.onStatusChange?.('reconnecting'); + } + } finally { + schedulePoll(); + } + }, delay); + }; + const scheduleReconnect = () => { if (disposed) { return; @@ -184,6 +218,11 @@ export function connectToMatchStream( } handlers.onStatusChange?.(reconnectAttempt > 0 ? 'reconnecting' : 'connecting'); const nextSocketUrl = resolveWebSocketBaseUrl(); + if (!nextSocketUrl) { + handlers.onStatusChange?.('connected'); + schedulePoll(reconnectAttempt > 0 ? 1000 : 0); + return; + } const nextSocket = new WebSocket(`${nextSocketUrl}/api/matches/${matchId}/ws`); socket = nextSocket; @@ -222,6 +261,7 @@ export function connectToMatchStream( return () => { disposed = true; clearReconnectTimer(); + clearPollTimer(); handlers.onStatusChange?.('disconnected'); if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { socket.close(); @@ -274,14 +314,29 @@ function normalizeBaseUrl(value?: string | null): string { return typeof value === 'string' ? value.trim().replace(/\/$/, '') : ''; } -function resolveWebSocketBaseUrl(): string { +function resolveWebSocketBaseUrl(): string | null { if (wsBaseUrl) { return wsBaseUrl; } - if (typeof window !== 'undefined') { - throw new Error('Live match stream is unavailable until the match service WebSocket URL is configured.'); + const derivedFromHttp = deriveWebSocketBaseUrlFromHttpBase(httpBaseUrl); + if (derivedFromHttp) { + return derivedFromHttp; } - throw new Error('Match service WebSocket URL is not configured.'); + return null; +} + +function deriveWebSocketBaseUrlFromHttpBase(input: string): string | null { + const normalized = normalizeBaseUrl(input); + if (!normalized) { + return null; + } + if (normalized.startsWith('https://')) { + return normalized.replace(/^https:\/\//i, 'wss://').replace(/\/api(?:\/realtime)?$/i, ''); + } + if (normalized.startsWith('http://')) { + return normalized.replace(/^http:\/\//i, 'ws://').replace(/\/api(?:\/realtime)?$/i, ''); + } + return null; } From 94c1b1ffc260a538c822cc473c3627687f7458a5 Mon Sep 17 00:00:00 2001 From: jraya106 Date: Fri, 15 May 2026 13:54:57 +0100 Subject: [PATCH 06/26] Polish hosted board labels and fallback sync --- apps/web/src/App.tsx | 40 +++++++++++++++++++++++++------ apps/web/src/lib/match-service.ts | 6 +++-- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5fa11f6..40ea381 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -198,6 +198,7 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH } satisfies MatchServiceRuntimeConfig); const [hostedRuntime, setHostedRuntime] = React.useState(false); const [activePage, setActivePage] = React.useState('Play'); + const openedBoardMatchRef = React.useRef(null); const [communityFocusGuestId, setCommunityFocusGuestId] = React.useState(null); const [historyFocusMatchId, setHistoryFocusMatchId] = React.useState(null); const [historyFocusGuestId, setHistoryFocusGuestId] = React.useState(null); @@ -1383,6 +1384,18 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH setAnalysisArrows([]); }, [authoritativeMatchId]); + React.useEffect(() => { + if (!authoritativeMatchId) { + openedBoardMatchRef.current = null; + return; + } + if (openedBoardMatchRef.current === authoritativeMatchId) { + return; + } + openedBoardMatchRef.current = authoritativeMatchId; + setActivePage('Play'); + }, [authoritativeMatchId]); + React.useEffect(() => { if (!authoritativeMatchId) { setAuthoritativeLive(false); @@ -1509,6 +1522,19 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH }; }, [authoritativeMatchId, over, applyGatewayGuestSessions, applyGatewayMatchClaims, applyGatewayAccountSessions]); + const playTabLabel = authoritativeMatchId + ? 'Match' + : hostedRuntime + ? 'Board' + : 'Play'; + const boardStatusLabel = authoritativeMatchId + ? (authoritativeLive ? 'Online Match Live' : 'Match Sync Reconnecting') + : hostedRuntime + ? 'Hosted Solo Board' + : 'Local Play Sandbox'; + const showHostedSoloBanner = hostedRuntime && !authoritativeMatchId; + const showHostedReconnectWarning = hostedRuntime && Boolean(authoritativeMatchId) && !authoritativeLive; + // Card draw logic React.useEffect(() => { if (dealPhase !== 'done') return; @@ -4366,7 +4392,7 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH CardChess
- {[['Play', hostedRuntime ? 'Practice' : 'Play'], ['Queue','Queue'], ['History','History'], ['Cards','Cards'], ['Rankings','Rankings'], ['Community','Community'], ['Status','Status'], ['Account','Account']].map(([pageKey, label], i) => ( + {[['Play', playTabLabel], ['Queue','Queue'], ['History','History'], ['Cards','Cards'], ['Rankings','Rankings'], ['Community','Community'], ['Status','Status'], ['Account','Account']].map(([pageKey, label], i) => (
{/* ── Board column ── */} -
- {hostedRuntime && ( +
+ {showHostedSoloBanner && (
- Practice board: for real online opponents, use the Queue tab. + Solo board: use the Queue tab to find a real online opponent.
)} {cardPending && ( @@ -4899,16 +4925,16 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH }}>
- {authoritativeLive ? 'Online Match Live' : hostedRuntime ? 'Hosted Fallback Active' : 'Local Fallback Active'} + {boardStatusLabel}
{authoritativeMatchIdRef.current ? `match ${authoritativeMatchIdRef.current.slice(-6)}` : 'no match'}
- {hostedRuntime && !authoritativeLive && ( + {showHostedReconnectWarning && (
- Backend sync is unavailable, so this session is running browser-side fallback instead of a real online match. + Live match sync is reconnecting, so updates may briefly fall back to slower refreshes until the stream is back.
+ +
+
+
) : (
From 7eec98819353db5f3dc98de85686faced3d4e9f6 Mon Sep 17 00:00:00 2001 From: jraya106 Date: Fri, 15 May 2026 14:09:57 +0100 Subject: [PATCH 08/26] Fallback hosted queue to direct guest sessions --- apps/web/src/App.tsx | 46 +++++++++++++++++++++++++++++++++++++- apps/web/src/QueuePage.tsx | 4 +++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 33da92b..4ecb7a2 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -31,7 +31,7 @@ import { type MatchServiceRuntimeConfig, type StoredRoomMeta, } from './lib/match-service'; -import { finalizeAccountMatch, finalizeGuestMatch, type GuestProfile, type MatchSeatClaim } from './lib/platform-service'; +import { createGuestSession, finalizeAccountMatch, finalizeGuestMatch, type GuestProfile, type MatchSeatClaim } from './lib/platform-service'; // ─── App ────────────────────────────────────────────────────────────────────── const AUTHORITATIVE_JOKER_MECHANICS = new Set([ @@ -601,6 +601,50 @@ export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceH blackProfileRef.current = blackProfile; }, [blackProfile]); + React.useEffect(() => { + if (!guestProfilesReady) return; + + let cancelled = false; + + const ensureGuestSeat = async (side: 'white' | 'black') => { + const stored = readStoredGuestIdentity(side); + const session = await createGuestSession({ + guestId: stored.guestId, + sessionSecret: stored.sessionSecret, + sessionToken: stored.sessionToken, + }); + if (cancelled) return; + guestSessionSecretsRef.current[side] = session.sessionSecret; + writeStoredGuestIdentity(side, session.guest.guestId, session.sessionSecret, { + sessionToken: session.sessionToken ?? null, + sessionExpiresAt: session.expiresAt ?? null, + }); + if (side === 'white') { + setWhiteProfile(session.guest); + } else { + setBlackProfile(session.guest); + } + }; + + const tasks: Promise[] = []; + if (!whiteProfile) { + tasks.push(ensureGuestSeat('white')); + } + if (!hostedRuntime && !blackProfile) { + tasks.push(ensureGuestSeat('black')); + } + if (tasks.length === 0) { + return; + } + void Promise.all(tasks).catch(() => { + // Keep the UI usable; queue page will surface join failures if platform calls still fail. + }); + + return () => { + cancelled = true; + }; + }, [guestProfilesReady, hostedRuntime, whiteProfile, blackProfile]); + React.useEffect(() => { if (typeof window === 'undefined') return; const matchId = authoritativeMatchIdRef.current; diff --git a/apps/web/src/QueuePage.tsx b/apps/web/src/QueuePage.tsx index 26bf66a..8b68139 100644 --- a/apps/web/src/QueuePage.tsx +++ b/apps/web/src/QueuePage.tsx @@ -405,7 +405,9 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps cursor: loading || restoringTickets || !profile ? 'not-allowed' : 'pointer', }} > - Join {queue} + {!profile + ? 'Preparing player...' + : `Join ${queue}`} ) : ticket.status === 'queued' ? ( + +
+
+ )} + + {activeAccount && ( +
+
+
+
Active sessions
+
+ This account can now stay signed in on multiple devices. The current seat keeps one active session token, and you can revoke any of the others here. +
+
+ {accountSession && otherSessions.length > 0 && ( + + )} +
+ {sessionOverviewLoading ? ( +
Loading active account sessions...
+ ) : sessionOverviewError ? ( +
{sessionOverviewError}
+ ) : !accountSession ? ( +
+ Claim or sign in to this account to inspect active devices. +
+ ) : ( +
+ + {otherSessions.length === 0 ? ( +
+ No other active devices are signed in right now. +
+ ) : ( + otherSessions.map((record, index) => ( + void revokeOtherSession(record.sessionToken)} + busy={busy} + /> + )) + )} +
+ )}
)} @@ -460,6 +1356,28 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS gap: '10px', }}>
Season view
+
+ +
{highlightedSeason ? ( <>
@@ -474,7 +1392,7 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS ) : (
- No season summary exists yet because this account has not completed an account-owned rated result. + No season summary exists yet for the selected official mode because this account has not completed an account-owned rated result there.
)} {displayedSeasonHistory.length > 0 && ( @@ -556,7 +1474,7 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS gap: '10px', }}>
- {selectedSeasonId ? 'Season matches' : 'Recent account matches'} + {selectedSeasonId || selectedModeId ? 'Filtered account matches' : 'Recent account matches'}
{recentMatchesLoading ? (
Loading archived matches...
@@ -664,6 +1582,573 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS )}
+ +
+
+
Enable account sign-in
+
+ Create a full reusable account directly, or add sign-in credentials to a handle you already claimed on this seat. +
+
+ + +
+ + {!accountSession && ( +
+ This path creates the account immediately and signs this seat in, while still bridging through the seat guest identity underneath. +
+ )} +
+
+ +
+
+
Email verification
+
+ Verified email is the gate for real recovery. Requests now queue into a durable account email outbox so verification and reset actions survive across sessions and devices. +
+
+ {authOverviewLoading ? ( +
Loading auth status...
+ ) : authOverview ? ( +
+
+ {authOverview.email ? authOverview.email : 'No account email configured yet'} +
+
+ {authOverview.emailVerified + ? `Verified ${formatDateTime(authOverview.emailVerifiedAt)}` + : authOverview.pendingEmailVerification + ? `Pending verification until ${formatDateTime(authOverview.verificationExpiresAt)}` + : 'Not verified yet'} +
+
+ ) : ( +
+ {authOverviewError || 'Claim and enable account sign-in to manage verification.'} +
+ )} +
+ +
+ + {verificationPreviewToken && ( +
+
Preview token: {verificationPreviewToken}
+ {verificationPreviewExpiry &&
Expires {formatDateTime(verificationPreviewExpiry)}
} +
+ )} + {verificationAccountId && ( +
+ Verification account: {verificationAccountId} +
+ )} +
+ +
+
+ +
+
+
Sign in on this seat
+
+ Use a handle or email. Successful sign-in also restores the playable guest identity for this seat. +
+
+ + +
+ + {accountSession && ( + + )} +
+
+
Password reset preview
+
+ Verified accounts can request a reset token. If preview mode is enabled, the token also appears here so recovery can still be tested locally while the real delivery pipeline runs. +
+
+ +
+ {resetPreview?.previewToken && ( +
+
Preview account: {resetPreview.previewAccountId}
+
Preview token: {resetPreview.previewToken}
+ {resetPreview.expiresAt &&
Expires {formatDateTime(resetPreview.expiresAt)}
} +
+ )} + + + +
+ +
+
+
+ +
+
+
Recent auth emails
+
+ This durable auth outbox tracks verification and password-reset delivery activity, including queued, delivered, retrying, and failed messages. You can still load the action link directly into this page or copy it for another device. +
+
+ {emailOutboxLoading ? ( +
Loading account email activity...
+ ) : emailOutboxError ? ( +
{emailOutboxError}
+ ) : !accountSession ? ( +
+ Sign in on this seat to inspect the durable auth outbox for this account. +
+ ) : (emailOutboxOverview?.deliveries.length ?? 0) === 0 ? ( +
+ No verification or reset emails have been queued for this account yet. +
+ ) : ( +
+ {(emailOutboxOverview?.deliveries ?? []).map((delivery) => ( +
+
+
+ {describeAccountEmailDeliveryKind(delivery.kind)} +
+
+ {delivery.status} +
+
+
+ {delivery.email} | {describeAccountEmailDeliveryStatus(delivery)} +
+
+ {delivery.subject} +
+
+ Attempts {delivery.attemptCount} + {delivery.provider ? ` | provider ${delivery.provider}` : ''} + {delivery.providerMessageId ? ` | id ${delivery.providerMessageId}` : ''} +
+ {delivery.failureReason && ( +
+ Last failure: {delivery.failureReason} +
+ )} + {delivery.actionUrl && ( +
+ {delivery.actionUrl} +
+ )} +
+ {delivery.actionUrl && ( + <> + + + + )} +
+
+ ))} +
+ )} +
+ +
+
+
Security activity
+
+ This audit trail tracks account-sensitive actions like sign-in, verification, password recovery, and device session changes. +
+
+ {securityOverviewLoading ? ( +
Loading security activity...
+ ) : securityOverviewError ? ( +
{securityOverviewError}
+ ) : !accountSession ? ( +
+ Sign in on this seat to inspect the account security activity feed. +
+ ) : (securityOverview?.events.length ?? 0) === 0 ? ( +
+ No recorded security activity exists for this account yet. +
+ ) : ( +
+ {(securityOverview?.events ?? []).map((event) => ( +
+
+ {describeAccountSecurityEvent(event.kind, event.detail)} +
+
+ {formatDateTime(event.createdAt)} +
+
+ ))} +
+ )} +
)}
@@ -685,6 +2170,78 @@ function MetaTile({ label, value }: { label: string; value: string }): React.Rea ); } +function AccountSessionCard({ + label, + accent, + record, + fallbackExpiresAt, + fallbackSessionToken, + actionLabel, + onAction, + busy = false, + disabledAction = false, +}: { + label: string; + accent: string; + record: AccountSessionRecord | null; + fallbackExpiresAt?: string; + fallbackSessionToken?: string; + actionLabel: string; + onAction?: () => void; + busy?: boolean; + disabledAction?: boolean; +}): React.ReactElement { + const sessionToken = record?.sessionToken ?? fallbackSessionToken ?? ''; + const createdAt = record?.createdAt; + const lastSeenAt = record?.lastSeenAt; + const expiresAt = record?.expiresAt ?? fallbackExpiresAt; + + return ( +
+
+
{label}
+
+ {describeSessionTokenFingerprint(sessionToken)} +
+
+
+ Expires {formatDateTime(expiresAt)} +
+
+ Last active {formatDateTime(lastSeenAt)} + {createdAt ? ` · Created ${formatDateTime(createdAt)}` : ''} +
+
+ +
+
+ ); +} + function RatingHistoryRow({ entry }: { entry: AccountRatingHistoryEntry }): React.ReactElement { const badgeColor = entry.result === 'win' @@ -777,6 +2334,10 @@ function SeasonSummaryRow({ summary }: { summary: NonNullable
Account Layer
-

Guest-to-account upgrade

+

Accounts and identity

- This is the first real account slice on top of guest identity. Each seat can claim a reusable handle and keep a renewable local account session without changing the live match flow yet. + Chess404 still bridges through seat guest identity for live play, but this page now supports direct account creation, cross-device sign-in, verification, and recovery so the platform can move toward real account-first onboarding without breaking hosted matches.
@@ -799,8 +2360,8 @@ export default function AccountPage({ gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: '18px', }}> - - + +
); diff --git a/apps/web/src/AdminModerationPage.tsx b/apps/web/src/AdminModerationPage.tsx new file mode 100644 index 0000000..c3ff64e --- /dev/null +++ b/apps/web/src/AdminModerationPage.tsx @@ -0,0 +1,569 @@ +import * as React from 'react'; +import { + fetchModerationAdminOverview, + resolveModerationReport, + type ModerationAdminOverview, +} from './lib/platform-service'; + +interface AdminModerationPageProps { + accountId?: string | null; + sessionToken?: string | null; + onOpenProfile?: (handle: string) => void; +} + +const STATUS_OPTIONS: Array<{ value: string; label: string }> = [ + { value: '', label: 'All reports' }, + { value: 'open', label: 'Open' }, + { value: 'under_review', label: 'Under review' }, + { value: 'resolved_actioned', label: 'Resolved: actioned' }, + { value: 'resolved_dismissed', label: 'Resolved: dismissed' }, +]; + +function formatDateTime(value?: string | null): string { + if (!value) { + return 'Unknown time'; + } + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + return value; + } + return new Date(timestamp).toLocaleString(); +} + +export default function AdminModerationPage({ + accountId, + sessionToken, + onOpenProfile, +}: AdminModerationPageProps): React.ReactElement { + const [overview, setOverview] = React.useState(null); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(''); + const [busyReportId, setBusyReportId] = React.useState(''); + const [filterStatus, setFilterStatus] = React.useState(''); + const [notes, setNotes] = React.useState>({}); + + const loadOverview = React.useCallback(async (nextStatus = filterStatus) => { + if (!accountId || !sessionToken) { + setOverview(null); + setError(''); + return; + } + setLoading(true); + setError(''); + try { + const next = await fetchModerationAdminOverview({ + accountId, + sessionToken, + limit: 24, + status: nextStatus, + }); + setOverview(next); + setFilterStatus(next.selectedStatus ?? nextStatus); + } catch (err) { + setOverview(null); + setError(err instanceof Error ? err.message : 'Failed to load moderation admin queue.'); + } finally { + setLoading(false); + } + }, [accountId, filterStatus, sessionToken]); + + React.useEffect(() => { + void loadOverview(filterStatus); + }, [loadOverview, filterStatus]); + + const handleResolve = React.useCallback(async ( + reportId: string, + action: 'under_review' | 'resolved_actioned' | 'resolved_dismissed', + restriction?: 'suspended' | 'banned' | 'clear' + ) => { + if (!accountId || !sessionToken) { + return; + } + setBusyReportId(reportId); + setError(''); + try { + const next = await resolveModerationReport({ + accountId, + sessionToken, + reportId, + action, + restriction, + note: notes[reportId] ?? '', + limit: 24, + status: filterStatus, + }); + setOverview(next); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update moderation review.'); + } finally { + setBusyReportId(''); + } + }, [accountId, filterStatus, notes, sessionToken]); + + const canManage = Boolean(accountId && sessionToken); + + return ( +
+
+

Moderation Admin

+
+ This review queue turns player reports into a real launch-grade moderation workflow. Admins can triage reports, move them into review, resolve them with action or dismissal, and apply real suspensions or bans when needed. +
+
+ + {!canManage ? ( +
+ Sign in with an account session on this device to load the moderation admin queue. +
+ ) : ( + <> +
+ + + {overview?.viewer?.handle ? ( +
+ Signed in as @{overview.viewer.handle} +
+ ) : null} +
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+
+
+
+
Report queue
+
+ Reports here are authoritative backend records, not browser-only flags. +
+
+
+ {overview ? `${overview.reports.length} visible report${overview.reports.length === 1 ? '' : 's'}` : 'No queue loaded yet'} +
+
+ + {(overview?.reports.length ?? 0) === 0 ? ( +
+ {loading ? 'Loading moderation reports…' : 'No reports match the current filter.'} +
+ ) : ( + overview?.reports.map((report) => ( +
+
+
+
+ + {report.status.replace(/_/g, ' ')} + + {report.category} +
+
+ {report.details?.trim() || 'No additional detail was provided by the reporting player.'} +
+
+
+
Reported {formatDateTime(report.createdAt)}
+
Updated {formatDateTime(report.updatedAt)}
+
+
+ +
+ + + +
+ + {report.resolutionNote ? ( +
+ Resolution note: {report.resolutionNote} +
+ ) : null} + + {report.targetRestriction ? ( +
+ {report.targetRestriction.kind} + {' '}is active for @{report.target.handle} + {report.targetRestriction.reason ? `: ${report.targetRestriction.reason}` : '.'} +
+ ) : null} + +