Ctrl K
ComunidadGuías…

plugins/hushle/src/renderClient.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Hushle renderClient — the React panel the activity host renders in a
 * voice room once an activity for the `hushle` plugin is active.
 *
 * The panel receives a `state` snapshot (the server-authoritative
 * reducer output, already projected for this viewer) and a `dispatch`
 * function that POSTs to the activity action route. It is purely a
 * presentational component: no HTTP, no DB — every action goes through
 * `dispatch`, and the host application is responsible for re-rendering
 * the panel with the next state once the dispatch round-trip completes.
 *
 * Built from the activity UI kit (`@lobbyforge/plugin-sdk/ui`); the phase
 * views live in `./ui/` — lobby, team setup, playing (a running turn and
 * the pause between turns) and the end screen.
 *
 * Locale strategy: the plugin ships its own `locales/*.json` tables,
 * registered through `@lobbyforge/plugin-sdk`, so it stays
 * self-contained and works in any host. The host tells it which
 * language to speak via `data-lf-locale`; see docs/TRANSLATING.md.
 */

'use client';

import { useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { detectLocale, loadPluginLocale, pickBestLocale } from '@lobbyforge/plugin-sdk';
import { ActivityShell } from '@lobbyforge/plugin-sdk/ui';
import { LOCALE_TABLES } from './locales.generated';
import { HUSHLE_PLUGIN_ID } from './plugin-id';
import type { HushleAction, HushleState } from './state';
import { EndedView } from './ui/ended';
import { HushleI18nProvider, createHushleI18n } from './ui/i18n';
import { LobbyView } from './ui/lobby';
import { EMPTY_TURN_LOG, advanceTurnLog, type HushleViewState, type PanelPlayer, type TurnLog } from './ui/model';
import { PlayingView } from './ui/playing';
import { TeamSetupView } from './ui/setup';
import type { ViewProps } from './ui/shared';
import { HUSHLE_CSS } from './ui/theme';

// Load the plugin's locale tables into the shared registry the
// moment the panel mounts. This is the one-line step that lets a
// community contributor add a new language: drop a new JSON file in
// `locales/`, run `pnpm i18n:sync`, and the panel + host language
// switcher pick it up automatically.
loadPluginLocale(HUSHLE_PLUGIN_ID, LOCALE_TABLES);

export interface HushlePanelCardPack {
  id: string;
  slug: string;
  name: string;
  /** Any language tag — hosts create packs in their own language. */
  language: string;
  cardCount: number;
  isBuiltIn: boolean;
}

export interface HushlePanelClientProps {
  state: HushleState;
  dispatch: (action: HushleAction) => void | Promise<void>;
  actorUserId: string;
  hostUserId: string | null;
  /** The people in the room — the voice channel and anyone who has acted — with their names. */
  players: Array<{ userId: string; name?: string | null }>;
  cardPacks?: HushlePanelCardPack[];
}

export type HushlePanelProps = HushlePanelClientProps;

export function HushlePanel(props: HushlePanelProps): ReactNode {
  const { dispatch, actorUserId, hostUserId, players, cardPacks } = props;
  // The projected state carries a few fields the reducer's type does not
  // (deckSize, cardsRemaining) — see HushleViewState.
  const state = props.state as HushleViewState;
  // Resolve the active locale against the plugin's actual locale list
  // so a user with `fr` falls back to the first available language
  // (en/tr) without showing raw keys.
  const locale = useMemo(
    () => pickBestLocale(HUSHLE_PLUGIN_ID, detectLocale('en')),
    // Re-run only on mount — the document lang doesn't change mid-session.
    []
  );
  const i18n = useMemo(() => createHushleI18n(locale), [locale]);
  const log = useTurnLog(state);
  const inRoom = players ?? [];
  const everyone = useRememberedPlayers(inRoom);
  const present = useMemo(() => new Set(inRoom.map((player) => player.userId)), [inRoom]);
  const view: ViewProps = { state, dispatch, actorUserId, hostUserId, players: everyone, present };

  return (
    <HushleI18nProvider value={i18n}>
      <ActivityShell className="hushle" data-phase={state.phase}>
        {/* React 19 hoists this into <head> once, like the kit's own sheet. */}
        <style href="hushle-ui-v1" precedence="hushle">
          {HUSHLE_CSS}
        </style>
        {state.phase === 'lobby' ? (
          <LobbyView {...view} cardPacks={cardPacks} />
        ) : state.phase === 'team_setup' ? (
          <TeamSetupView {...view} />
        ) : state.phase === 'playing' ? (
          <PlayingView {...view} log={log} />
        ) : (
          <EndedView {...view} />
        )}
      </ActivityShell>
    </HushleI18nProvider>
  );
}

/**
 * Everyone the panel has been given a name for this session. The host
 * passes the people in the room NOW; a player who steps out stays on their
 * team and keeps their name here instead of becoming "Unknown player".
 */
function useRememberedPlayers(players: PanelPlayer[]): PanelPlayer[] {
  const book = useRef(new Map<string, string>());
  for (const player of players) {
    const name = player.name?.trim();
    if (name) book.current.set(player.userId, name);
  }
  const here = new Set(players.map((player) => player.userId));
  const earlier = [...book.current]
    .filter(([userId]) => !here.has(userId))
    .map(([userId, name]) => ({ userId, name }));
  return earlier.length === 0 ? players : [...players, ...earlier];
}

/**
 * The "this turn" log, advanced once per new state snapshot. This is
 * React's pattern for state derived from the previous props: compare with
 * the snapshot stored alongside, and update during render.
 */
function useTurnLog(state: HushleViewState): TurnLog {
  const [track, setTrack] = useState(() => ({ state, log: advanceTurnLog(EMPTY_TURN_LOG, null, state) }));
  if (track.state !== state) {
    const next = { state, log: advanceTurnLog(track.log, track.state, state) };
    setTrack(next);
    return next.log;
  }
  return track.log;
}