trails/apps/planner/app/lib/use-participants.ts
Ullrich Schäfer 4f0d4a34b3
feat(planner): redesign topbar with tokens + primitives, presentational
Extract a presentational Topbar driven by plain props, so SessionView
is the container (reads Yjs/awareness) and the topbar can render in the
/dev/ui gallery without a live session.

- New useParticipants(yjs) hook: awareness -> sorted Participant[] +
  renameLocal. Replaces ParticipantList's inline logic.
- New presentational ParticipantAvatars (Avatar + Host Badge + inline
  rename) and Topbar (token-styled shell; undo/redo via IconButton;
  connection status with a live dot). ProfileSelector/Export/Save are
  passed in as slots (their own restyle is separate).
- SessionView composes <Topbar> from real data; delete ParticipantList.
- /dev/ui gains a "Topbar - configurations" section (solo, multiplayer
  + computing, guest/connecting). Surfaced a narrow-width overflow, now
  fixed by making the participant strip the shrink/clip element.

Connection status text ("Connected"/"Verbunden") and behavior are
unchanged, so existing planner E2E selectors still match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:51:27 +02:00

80 lines
2.1 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import type { YjsState } from "./use-yjs";
import { electHost } from "./host-election";
export interface Participant {
clientId: number;
name: string;
color: string;
isHost: boolean;
isLocal: boolean;
}
/**
* Derive the live participant list from Yjs awareness, sorted local → host →
* alphabetical. Presentational components (ParticipantAvatars, the gallery)
* take the resulting plain array so they can be rendered without a live
* session. Accepts a null session so it can be called before SessionView's
* connecting-gate early return.
*/
export function useParticipants(yjs: YjsState | null): {
participants: Participant[];
renameLocal: (name: string) => void;
} {
const [participants, setParticipants] = useState<Participant[]>([]);
const update = useCallback(() => {
if (!yjs) {
setParticipants([]);
return;
}
const states = yjs.awareness.getStates() as Map<
number,
Record<string, unknown>
>;
const localId = yjs.awareness.clientID;
const list: Participant[] = [];
states.forEach((state, clientId) => {
const user = state.user as { color: string; name: string } | undefined;
if (!user) return;
const { isHost } = electHost(states, clientId);
list.push({
clientId,
name: user.name,
color: user.color,
isHost,
isLocal: clientId === localId,
});
});
list.sort((a, b) => {
if (a.isLocal !== b.isLocal) return a.isLocal ? -1 : 1;
if (a.isHost !== b.isHost) return a.isHost ? -1 : 1;
return a.name.localeCompare(b.name);
});
setParticipants(list);
}, [yjs]);
useEffect(() => {
if (!yjs) return;
yjs.awareness.on("change", update);
update();
return () => {
yjs.awareness.off("change", update);
};
}, [yjs, update]);
const renameLocal = useCallback(
(name: string) => {
const trimmed = name.trim();
if (yjs && trimmed && trimmed.length <= 20) {
yjs.setUserName(trimmed);
}
},
[yjs],
);
return { participants, renameLocal };
}