Merge pull request #600 from trails-cool/feat/planner-topbar

feat(planner): redesign topbar with tokens + primitives
This commit is contained in:
Ullrich Schäfer 2026-07-15 23:55:59 +02:00 committed by GitHub
commit 5e75894372
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 378 additions and 183 deletions

View file

@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, Badge } from "@trails-cool/ui";
import type { Participant } from "~/lib/use-participants";
interface ParticipantAvatarsProps {
participants: Participant[];
/** Called when the local participant commits a new display name. */
onRenameLocal?: (name: string) => void;
}
/**
* Presentational participant strip: a colored Avatar per participant, the
* local user's name inline-editable, and a Host badge. Driven entirely by the
* plain `participants` array so it renders in the /dev/ui gallery without a
* live session.
*/
export function ParticipantAvatars({
participants,
onRenameLocal,
}: ParticipantAvatarsProps) {
const { t } = useTranslation("planner");
const [editing, setEditing] = useState(false);
const [value, setValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
const startEditing = (name: string) => {
setValue(name);
setEditing(true);
};
const commit = () => {
onRenameLocal?.(value);
setEditing(false);
};
return (
<div className="flex items-center gap-2">
{participants.map((p) => (
<div key={p.clientId} className="flex items-center gap-1.5">
<Avatar name={p.name} color={p.color} size="sm" />
{p.isLocal && editing ? (
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
else if (e.key === "Escape") setEditing(false);
}}
maxLength={20}
className="w-24 rounded border border-border bg-bg-raised px-1.5 py-0.5 text-base text-text-hi focus:border-accent focus:outline-none sm:text-xs"
/>
) : (
<button
type="button"
onClick={p.isLocal ? () => startEditing(p.name) : undefined}
className={
p.isLocal
? "cursor-pointer text-xs text-text-hi hover:underline"
: "cursor-default text-xs text-text-md"
}
title={p.isLocal ? t("participants.editName") : p.name}
>
{p.name}
{p.isLocal && (
<span className="ml-0.5 text-text-lo">
{t("participants.you")}
</span>
)}
</button>
)}
{p.isHost && <Badge tone="accent">{t("participants.host")}</Badge>}
</div>
))}
</div>
);
}

View file

@ -1,136 +0,0 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { YjsState } from "~/lib/use-yjs";
import { electHost } from "~/lib/host-election";
interface Participant {
clientId: number;
name: string;
color: string;
isHost: boolean;
isLocal: boolean;
}
interface ParticipantListProps {
yjs: YjsState;
}
export function ParticipantList({ yjs }: ParticipantListProps) {
const { t } = useTranslation("planner");
const [participants, setParticipants] = useState<Participant[]>([]);
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const updateParticipants = useCallback(() => {
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,
});
});
// Sort: local user first, then host, then alphabetical
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.awareness]);
useEffect(() => {
yjs.awareness.on("change", updateParticipants);
updateParticipants();
return () => {
yjs.awareness.off("change", updateParticipants);
};
}, [yjs.awareness, updateParticipants]);
const startEditing = () => {
const localParticipant = participants.find((p) => p.isLocal);
if (localParticipant) {
setEditValue(localParticipant.name);
setEditing(true);
}
};
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
const commitEdit = () => {
const trimmed = editValue.trim();
if (trimmed && trimmed.length <= 20) {
yjs.setUserName(trimmed);
}
setEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
commitEdit();
} else if (e.key === "Escape") {
setEditing(false);
}
};
return (
<div className="flex items-center gap-1.5">
{participants.map((p) => (
<div key={p.clientId} className="flex items-center gap-1">
<span
className="inline-block h-2.5 w-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: p.color }}
/>
{p.isLocal && editing ? (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
maxLength={20}
className="w-20 rounded border border-gray-300 px-1 py-0 text-base sm:text-xs text-gray-700 focus:border-blue-500 focus:outline-none"
/>
) : (
<button
type="button"
onClick={p.isLocal ? startEditing : undefined}
className={`text-xs text-gray-700 ${p.isLocal ? "cursor-pointer hover:underline" : "cursor-default"}`}
title={p.isLocal ? t("participants.editName") : p.name}
>
{p.name}
{p.isLocal && (
<span className="ml-0.5 text-gray-400">{t("participants.you")}</span>
)}
</button>
)}
{p.isHost && (
<span className="rounded-full bg-green-100 px-1.5 py-0 text-[10px] text-green-700">
{t("participants.host")}
</span>
)}
</div>
))}
</div>
);
}

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">

View file

@ -0,0 +1,104 @@
import type { ReactNode } from "react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { IconButton } from "@trails-cool/ui";
import { ParticipantAvatars } from "~/components/ParticipantAvatars";
import type { Participant } from "~/lib/use-participants";
interface TopbarProps {
participants: Participant[];
onRenameLocal?: (name: string) => void;
connected: boolean;
sessionShortId: string;
canUndo: boolean;
canRedo: boolean;
onUndo: () => void;
onRedo: () => void;
computing?: boolean;
/** App-specific interactive slot (profile selector). */
profileSlot?: ReactNode;
/** Right-aligned action slot (save-to-journal, export). */
actions?: ReactNode;
}
const UndoIcon = () => (
<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>
);
const RedoIcon = () => (
<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>
);
export function Topbar({
participants,
onRenameLocal,
connected,
sessionShortId,
canUndo,
canRedo,
onUndo,
onRedo,
computing = false,
profileSlot,
actions,
}: TopbarProps) {
const { t } = useTranslation("planner");
return (
<header className="flex items-center justify-between gap-2 border-b border-border bg-bg-raised px-3 py-1.5 pt-[max(0.375rem,env(safe-area-inset-top))] sm:px-4 sm:py-2">
<div className="flex min-w-0 items-center gap-1.5 sm:gap-4">
<Link
to="/"
className="hidden shrink-0 text-lg font-semibold tracking-tight text-text-hi hover:text-accent sm:block"
>
{t("title")}
</Link>
<div className="shrink-0">{profileSlot}</div>
<div className="hidden min-w-0 overflow-hidden sm:block">
<ParticipantAvatars
participants={participants}
onRenameLocal={onRenameLocal}
/>
</div>
<div className="hidden shrink-0 gap-0.5 sm:flex">
<IconButton
size="sm"
label={t("undo.tooltip")}
onClick={onUndo}
disabled={!canUndo}
>
<UndoIcon />
</IconButton>
<IconButton
size="sm"
label={t("redo.tooltip")}
onClick={onRedo}
disabled={!canRedo}
>
<RedoIcon />
</IconButton>
</div>
</div>
<div className="flex shrink-0 items-center gap-2 sm:gap-3">
{actions}
{computing && (
<span className="hidden text-xs text-accent sm:inline">
{t("computingRoute")}
</span>
)}
<span className="hidden items-center gap-1.5 text-sm text-text-md sm:inline-flex">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${connected ? "bg-accent" : "bg-text-lo"}`}
aria-hidden
/>
{connected ? t("connected") : t("connecting")} · {sessionShortId}
</span>
</div>
</header>
);
}

View file

@ -0,0 +1,80 @@
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 };
}

View file

@ -8,6 +8,40 @@ import {
Input,
SegmentedControl,
} from "@trails-cool/ui";
import { Topbar } from "~/components/Topbar";
import type { Participant } from "~/lib/use-participants";
const SOLO: Participant[] = [
{ clientId: 1, name: "ulle", color: "#4A6B40", isHost: true, isLocal: true },
];
const PARTY: Participant[] = [
{ clientId: 1, name: "ulle", color: "#4A6B40", isHost: true, isLocal: true },
{ clientId: 2, name: "Mara", color: "#8B6D3A", isHost: false, isLocal: false },
{ clientId: 3, name: "Sam", color: "#C46040", isHost: false, isLocal: false },
];
const noop = () => {};
const profilePlaceholder = (
<Button variant="ghost" size="sm">
🚴 Cycling
</Button>
);
const actionsPlaceholder = (
<Button variant="secondary" size="sm">
Export GPX
</Button>
);
function TopbarConfig({ caption, children }: { caption: string; children: ReactNode }) {
return (
<div className="space-y-1.5">
<p className="text-xs text-text-md">{caption}</p>
<div className="overflow-hidden rounded-lg border border-border">
{children}
</div>
</div>
);
}
const UndoIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@ -127,6 +161,52 @@ export default function DevUi() {
</span>
</Section>
<section className="space-y-4">
<h2 className="font-mono text-xs uppercase tracking-wider text-text-lo">
Topbar · configurations
</h2>
<TopbarConfig caption="Solo · connected · host">
<Topbar
participants={SOLO}
connected
sessionShortId="a83eddb4"
canUndo={false}
canRedo={false}
onUndo={noop}
onRedo={noop}
profileSlot={profilePlaceholder}
actions={actionsPlaceholder}
/>
</TopbarConfig>
<TopbarConfig caption="Multiplayer · connected · computing route · undo available">
<Topbar
participants={PARTY}
connected
sessionShortId="a83eddb4"
canUndo
canRedo={false}
onUndo={noop}
onRedo={noop}
computing
profileSlot={profilePlaceholder}
actions={actionsPlaceholder}
/>
</TopbarConfig>
<TopbarConfig caption="Guest · connecting (offline dot)">
<Topbar
participants={PARTY.slice(1)}
connected={false}
sessionShortId="a83eddb4"
canUndo={false}
canRedo={false}
onUndo={noop}
onRedo={noop}
profileSlot={profilePlaceholder}
actions={actionsPlaceholder}
/>
</TopbarConfig>
</section>
<Section title="Card">
<Card className="w-64">
<h3 className="font-medium text-text-hi">Subtle card</h3>