diff --git a/apps/planner/app/components/ParticipantAvatars.tsx b/apps/planner/app/components/ParticipantAvatars.tsx new file mode 100644 index 0000000..d821d05 --- /dev/null +++ b/apps/planner/app/components/ParticipantAvatars.tsx @@ -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(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 ( +
+ {participants.map((p) => ( +
+ + {p.isLocal && editing ? ( + 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" + /> + ) : ( + + )} + {p.isHost && {t("participants.host")}} +
+ ))} +
+ ); +} diff --git a/apps/planner/app/components/ParticipantList.tsx b/apps/planner/app/components/ParticipantList.tsx deleted file mode 100644 index 154c3d9..0000000 --- a/apps/planner/app/components/ParticipantList.tsx +++ /dev/null @@ -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([]); - const [editing, setEditing] = useState(false); - const [editValue, setEditValue] = useState(""); - const inputRef = useRef(null); - - const updateParticipants = useCallback(() => { - const states = yjs.awareness.getStates() as Map>; - 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 ( -
- {participants.map((p) => ( -
- - {p.isLocal && editing ? ( - 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" - /> - ) : ( - - )} - {p.isHost && ( - - {t("participants.host")} - - )} -
- ))} -
- ); -} diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 38650d4..a1b4a66 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -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(null); @@ -226,51 +227,30 @@ export function SessionView({ sessionId, hasJournalCallback, returnUrl, initialW return ( <> -
-
- - {t("title")} - - -
- -
-
- - -
-
-
- {hasJournalCallback && ( - - )} - - {computing && ( - {t("computingRoute")} - )} - - {yjs.connected ? t("connected") : t("connecting")} · {sessionId.slice(0, 8)} - -
-
+ } + actions={ + <> + {hasJournalCallback && ( + + )} + + + } + />
diff --git a/apps/planner/app/components/Topbar.tsx b/apps/planner/app/components/Topbar.tsx new file mode 100644 index 0000000..d61f48c --- /dev/null +++ b/apps/planner/app/components/Topbar.tsx @@ -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 = () => ( + + + + +); +const RedoIcon = () => ( + + + + +); + +export function Topbar({ + participants, + onRenameLocal, + connected, + sessionShortId, + canUndo, + canRedo, + onUndo, + onRedo, + computing = false, + profileSlot, + actions, +}: TopbarProps) { + const { t } = useTranslation("planner"); + + return ( +
+
+ + {t("title")} + +
{profileSlot}
+
+ +
+
+ + + + + + +
+
+
+ {actions} + {computing && ( + + {t("computingRoute")} + + )} + + + {connected ? t("connected") : t("connecting")} · {sessionShortId} + +
+
+ ); +} diff --git a/apps/planner/app/lib/use-participants.ts b/apps/planner/app/lib/use-participants.ts new file mode 100644 index 0000000..f0737dc --- /dev/null +++ b/apps/planner/app/lib/use-participants.ts @@ -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([]); + + const update = useCallback(() => { + if (!yjs) { + setParticipants([]); + return; + } + const states = yjs.awareness.getStates() as Map< + number, + Record + >; + 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 }; +} diff --git a/apps/planner/app/routes/dev.ui.tsx b/apps/planner/app/routes/dev.ui.tsx index e463757..e662bf3 100644 --- a/apps/planner/app/routes/dev.ui.tsx +++ b/apps/planner/app/routes/dev.ui.tsx @@ -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 = ( + +); +const actionsPlaceholder = ( + +); + +function TopbarConfig({ caption, children }: { caption: string; children: ReactNode }) { + return ( +
+

{caption}

+
+ {children} +
+
+ ); +} const UndoIcon = () => ( @@ -127,6 +161,52 @@ export default function DevUi() { +
+

+ Topbar · configurations +

+ + + + + + + + + + +

Subtle card