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>
This commit is contained in:
Ullrich Schäfer 2026-07-15 23:51:26 +02:00
parent 9463c5b1e5
commit 4f0d4a34b3
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
6 changed files with 378 additions and 183 deletions

View file

@ -1,6 +1,5 @@
import { Suspense, lazy, useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import L from "leaflet";
import type { TFunction } from "i18next";
import * as Sentry from "@sentry/react";
@ -13,7 +12,8 @@ import { ProfileSelector } from "~/components/ProfileSelector";
import { ExportButton } from "~/components/ExportButton";
import { SaveToJournalButton } from "~/components/SaveToJournalButton";
import { YjsDebugPanel } from "~/components/YjsDebugPanel";
import { ParticipantList } from "~/components/ParticipantList";
import { Topbar } from "~/components/Topbar";
import { useParticipants } from "~/lib/use-participants";
import { NotesPanel } from "~/components/NotesPanel";
const PlannerMap = lazy(() =>
@ -169,6 +169,7 @@ export function SessionView({ sessionId, hasJournalCallback, returnUrl, initialW
const { computing, routeError, routeStats, requestRoute } = useRouting(yjs, sessionId);
const { canUndo, canRedo, undo, redo } = useUndo(yjs?.undoManager ?? null);
useUndoShortcuts(yjs?.undoManager ?? null);
const { participants, renameLocal } = useParticipants(yjs);
const days = useDays(yjs);
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
const [highlightedWaypoint, setHighlightedWaypoint] = useState<number | null>(null);
@ -226,51 +227,30 @@ export function SessionView({ sessionId, hasJournalCallback, returnUrl, initialW
return (
<>
<header className="flex items-center justify-between gap-2 border-b border-gray-200 px-3 py-1.5 pt-[max(0.375rem,env(safe-area-inset-top))] sm:px-4 sm:py-2">
<div className="flex items-center gap-1.5 sm:gap-4 min-w-0">
<Link to="/" className="hidden text-lg font-semibold text-gray-900 hover:text-blue-600 sm:block">
{t("title")}
</Link>
<ProfileSelector yjs={yjs} />
<div className="hidden sm:block">
<ParticipantList yjs={yjs} />
</div>
<div className="hidden sm:flex gap-1">
<button
onClick={undo}
disabled={!canUndo}
title={t("undo.tooltip")}
className="rounded px-1.5 py-1 text-gray-500 hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
</button>
<button
onClick={redo}
disabled={!canRedo}
title={t("redo.tooltip")}
className="rounded px-1.5 py-1 text-gray-500 hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.13-9.36L23 10"/></svg>
</button>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
{hasJournalCallback && (
<SaveToJournalButton
yjs={yjs}
sessionId={sessionId}
returnUrl={returnUrl}
/>
)}
<ExportButton yjs={yjs} />
{computing && (
<span className="hidden text-xs text-blue-600 sm:inline">{t("computingRoute")}</span>
)}
<span className="hidden text-sm text-gray-500 sm:inline">
{yjs.connected ? t("connected") : t("connecting")} · {sessionId.slice(0, 8)}
</span>
</div>
</header>
<Topbar
participants={participants}
onRenameLocal={renameLocal}
connected={yjs.connected}
sessionShortId={sessionId.slice(0, 8)}
canUndo={canUndo}
canRedo={canRedo}
onUndo={undo}
onRedo={redo}
computing={computing}
profileSlot={<ProfileSelector yjs={yjs} />}
actions={
<>
{hasJournalCallback && (
<SaveToJournalButton
yjs={yjs}
sessionId={sessionId}
returnUrl={returnUrl}
/>
)}
<ExportButton yjs={yjs} />
</>
}
/>
<div className="flex flex-1 overflow-hidden">
<main className="flex-1 flex flex-col">
<div className="relative flex-1">