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:
parent
9463c5b1e5
commit
4f0d4a34b3
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 { 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">
|
||||
|
|
|
|||
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,
|
||||
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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue