Merge pull request #600 from trails-cool/feat/planner-topbar
feat(planner): redesign topbar with tokens + primitives
This commit is contained in:
commit
5e75894372
6 changed files with 378 additions and 183 deletions
87
apps/planner/app/components/ParticipantAvatars.tsx
Normal file
87
apps/planner/app/components/ParticipantAvatars.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Suspense, lazy, useState, useCallback, useEffect, useRef } from "react";
|
import { Suspense, lazy, useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
|
||||||
import L from "leaflet";
|
import L from "leaflet";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
|
|
@ -13,7 +12,8 @@ import { ProfileSelector } from "~/components/ProfileSelector";
|
||||||
import { ExportButton } from "~/components/ExportButton";
|
import { ExportButton } from "~/components/ExportButton";
|
||||||
import { SaveToJournalButton } from "~/components/SaveToJournalButton";
|
import { SaveToJournalButton } from "~/components/SaveToJournalButton";
|
||||||
import { YjsDebugPanel } from "~/components/YjsDebugPanel";
|
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";
|
import { NotesPanel } from "~/components/NotesPanel";
|
||||||
|
|
||||||
const PlannerMap = lazy(() =>
|
const PlannerMap = lazy(() =>
|
||||||
|
|
@ -169,6 +169,7 @@ export function SessionView({ sessionId, hasJournalCallback, returnUrl, initialW
|
||||||
const { computing, routeError, routeStats, requestRoute } = useRouting(yjs, sessionId);
|
const { computing, routeError, routeStats, requestRoute } = useRouting(yjs, sessionId);
|
||||||
const { canUndo, canRedo, undo, redo } = useUndo(yjs?.undoManager ?? null);
|
const { canUndo, canRedo, undo, redo } = useUndo(yjs?.undoManager ?? null);
|
||||||
useUndoShortcuts(yjs?.undoManager ?? null);
|
useUndoShortcuts(yjs?.undoManager ?? null);
|
||||||
|
const { participants, renameLocal } = useParticipants(yjs);
|
||||||
const days = useDays(yjs);
|
const days = useDays(yjs);
|
||||||
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
|
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
|
||||||
const [highlightedWaypoint, setHighlightedWaypoint] = useState<number | null>(null);
|
const [highlightedWaypoint, setHighlightedWaypoint] = useState<number | null>(null);
|
||||||
|
|
@ -226,51 +227,30 @@ export function SessionView({ sessionId, hasJournalCallback, returnUrl, initialW
|
||||||
|
|
||||||
return (
|
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">
|
<Topbar
|
||||||
<div className="flex items-center gap-1.5 sm:gap-4 min-w-0">
|
participants={participants}
|
||||||
<Link to="/" className="hidden text-lg font-semibold text-gray-900 hover:text-blue-600 sm:block">
|
onRenameLocal={renameLocal}
|
||||||
{t("title")}
|
connected={yjs.connected}
|
||||||
</Link>
|
sessionShortId={sessionId.slice(0, 8)}
|
||||||
<ProfileSelector yjs={yjs} />
|
canUndo={canUndo}
|
||||||
<div className="hidden sm:block">
|
canRedo={canRedo}
|
||||||
<ParticipantList yjs={yjs} />
|
onUndo={undo}
|
||||||
</div>
|
onRedo={redo}
|
||||||
<div className="hidden sm:flex gap-1">
|
computing={computing}
|
||||||
<button
|
profileSlot={<ProfileSelector yjs={yjs} />}
|
||||||
onClick={undo}
|
actions={
|
||||||
disabled={!canUndo}
|
<>
|
||||||
title={t("undo.tooltip")}
|
{hasJournalCallback && (
|
||||||
className="rounded px-1.5 py-1 text-gray-500 hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent"
|
<SaveToJournalButton
|
||||||
>
|
yjs={yjs}
|
||||||
<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>
|
sessionId={sessionId}
|
||||||
</button>
|
returnUrl={returnUrl}
|
||||||
<button
|
/>
|
||||||
onClick={redo}
|
)}
|
||||||
disabled={!canRedo}
|
<ExportButton yjs={yjs} />
|
||||||
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>
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<main className="flex-1 flex flex-col">
|
<main className="flex-1 flex flex-col">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
|
|
|
||||||
104
apps/planner/app/components/Topbar.tsx
Normal file
104
apps/planner/app/components/Topbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
apps/planner/app/lib/use-participants.ts
Normal file
80
apps/planner/app/lib/use-participants.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,40 @@ import {
|
||||||
Input,
|
Input,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
} from "@trails-cool/ui";
|
} 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 = () => (
|
const UndoIcon = () => (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<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>
|
</span>
|
||||||
</Section>
|
</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">
|
<Section title="Card">
|
||||||
<Card className="w-64">
|
<Card className="w-64">
|
||||||
<h3 className="font-medium text-text-hi">Subtle card</h3>
|
<h3 className="font-medium text-text-hi">Subtle card</h3>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue