feat: add URL parameter syncing for session and player

Implement URL search parameter synchronization for `session` and
`player`
to allow for bookmarkable links. Added `sessionName` to the journal
stream state to support displaying human-readable session names in the
UI. Improved layout transitions and updated the JournalPanel to show
more detailed connection information.
This commit is contained in:
hypercross 2026-07-06 16:12:34 +08:00
parent 6b5fcdc051
commit 13f454de9c
4 changed files with 110 additions and 13 deletions

View File

@ -114,8 +114,11 @@ const App: Component = () => {
</button> </button>
</div> </div>
</header> </header>
{/* fill th rest of the space*/} {/* fill the rest of the space */}
<div class="fixed top-16 left-0 right-0 bottom-0 overflow-auto"> <div
class="fixed top-16 left-0 right-0 bottom-0 overflow-auto transition-all duration-300"
classList={{ "md:pr-[420px]": isJournalOpen() }}
>
<main class="max-w-4xl mx-auto px-4 py-8 pt-4 md:ml-64 2xl:ml-auto flex justify-center items-center"> <main class="max-w-4xl mx-auto px-4 py-8 pt-4 md:ml-64 2xl:ml-auto flex justify-center items-center">
<Article <Article
class="prose text-black prose-sm max-w-full flex-1" class="prose text-black prose-sm max-w-full flex-1"

View File

@ -10,6 +10,7 @@ import {
createSession, createSession,
deleteSession, deleteSession,
disconnectStream, disconnectStream,
setSessionId,
} from "../stores/journalStream"; } from "../stores/journalStream";
import { journalSetState } from "../stores/journalStream"; import { journalSetState } from "../stores/journalStream";
@ -38,7 +39,7 @@ export const ConnectionBar: Component = () => {
const handleSessionSelect = async (id: string) => { const handleSessionSelect = async (id: string) => {
try { try {
await hydrateFromServer(id); await hydrateFromServer(id);
journalSetState("sessionId", id); setSessionId(id);
} catch (e) { } catch (e) {
setError("Failed to load session"); setError("Failed to load session");
} }
@ -56,7 +57,7 @@ export const ConnectionBar: Component = () => {
<span class="text-xs text-gray-500">connected</span> <span class="text-xs text-gray-500">connected</span>
<Show when={stream.sessionId}> <Show when={stream.sessionId}>
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded"> <span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
{stream.sessionId} {stream.sessionName || stream.sessionId}
</span> </span>
</Show> </Show>
<div class="flex-1" /> <div class="flex-1" />

View File

@ -11,6 +11,7 @@ import {
hydrateFromServer, hydrateFromServer,
useJournalStream, useJournalStream,
setMyName, setMyName,
setSessionId,
} from "../stores/journalStream"; } from "../stores/journalStream";
import { ConnectionBar } from "./ConnectionBar"; import { ConnectionBar } from "./ConnectionBar";
import { StreamView } from "./StreamView"; import { StreamView } from "./StreamView";
@ -26,9 +27,9 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
return ( return (
<> <>
{/* Backdrop — hidden on desktop since panel doesn't overlay content */} {/* Backdrop — only on mobile, starts below header */}
<div <div
class="fixed inset-0 bg-black/30 z-40 md:hidden" class="fixed top-16 inset-x-0 bottom-0 bg-black/30 z-40 md:hidden"
classList={{ hidden: !props.open }} classList={{ hidden: !props.open }}
onClick={props.onClose} onClick={props.onClose}
/> />
@ -43,10 +44,9 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
> >
{/* Header */} {/* Header */}
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0"> <div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 min-w-0">
<h2 class="text-sm font-semibold text-gray-700">Journal</h2>
<span <span
class="w-2 h-2 rounded-full" class="w-2 h-2 rounded-full shrink-0"
classList={{ classList={{
"bg-gray-400": stream.connectionStatus === "disconnected", "bg-gray-400": stream.connectionStatus === "disconnected",
"bg-yellow-400 animate-pulse": "bg-yellow-400 animate-pulse":
@ -56,10 +56,27 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
}} }}
title={stream.connectionStatus} title={stream.connectionStatus}
/> />
<Show
when={stream.connected && stream.sessionId}
fallback={
<h2 class="text-sm font-semibold text-gray-700 truncate">
Disconnected
</h2>
}
>
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-700 truncate leading-tight">
{stream.myName}
</p>
<p class="text-[10px] text-gray-400 truncate leading-tight">
{stream.sessionName || stream.sessionId}
</p>
</div>
</Show>
</div> </div>
<button <button
onClick={props.onClose} onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm px-1" class="text-gray-400 hover:text-gray-600 text-sm px-1 shrink-0"
title="Hide panel" title="Hide panel"
> >
@ -114,6 +131,7 @@ const ConnectDialog: Component = () => {
try { try {
setMyName(name); setMyName(name);
const sessionId = stream.sessionId || "default"; const sessionId = stream.sessionId || "default";
setSessionId(sessionId);
await hydrateFromServer(sessionId); await hydrateFromServer(sessionId);
await connectStream(sessionId, brokerUrl); await connectStream(sessionId, brokerUrl);
} catch (e) { } catch (e) {

View File

@ -22,6 +22,8 @@ import { getMessageType, validatePayload } from "../journal/registry";
export interface JournalStreamState { export interface JournalStreamState {
sessionId: string | null; sessionId: string | null;
/** Human-readable name for the current session (from manifest) */
sessionName: string | null;
/** Full message log, oldest-first */ /** Full message log, oldest-first */
messages: StreamMessage[]; messages: StreamMessage[];
/** Last sequence number per sender */ /** Last sequence number per sender */
@ -81,19 +83,30 @@ function loadPersisted(): {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const persisted = loadPersisted(); const persisted = loadPersisted();
const urlParams = readUrlParams();
// URL params override localStorage if present
const initialName = urlParams.playerName ?? persisted.myName;
const initialSession = urlParams.sessionId ?? persisted.lastSessionId;
const [state, setState] = createStore<JournalStreamState>({ const [state, setState] = createStore<JournalStreamState>({
sessionId: persisted.lastSessionId, sessionId: initialSession,
sessionName: null,
messages: [], messages: [],
senderSeq: {}, senderSeq: {},
revealedPaths: new Set(), revealedPaths: new Set(),
connected: false, connected: false,
connectionStatus: "disconnected", connectionStatus: "disconnected",
connectionError: null, connectionError: null,
myName: persisted.myName, myName: initialName,
brokerUrl: persisted.brokerUrl, brokerUrl: persisted.brokerUrl,
}); });
// Sync initial URL params if they came from localStorage (not URL)
if (initialName && !urlParams.playerName) syncUrlParam("player", initialName);
if (initialSession && !urlParams.sessionId)
syncUrlParam("session", initialSession);
export { setState as journalSetState }; export { setState as journalSetState };
const [sessionList, setSessionList] = createSignal<SessionManifest>({ const [sessionList, setSessionList] = createSignal<SessionManifest>({
@ -104,13 +117,70 @@ export { sessionList as sessions };
/** /**
* Change the current player's name. Persisted to localStorage so it * Change the current player's name. Persisted to localStorage so it
* survives page reloads. * survives page reloads. Also syncs to URL search param.
*/ */
export function setMyName(name: string): void { export function setMyName(name: string): void {
setState("myName", name); setState("myName", name);
if (typeof localStorage !== "undefined") { if (typeof localStorage !== "undefined") {
localStorage.setItem(LS_PLAYER_NAME, name); localStorage.setItem(LS_PLAYER_NAME, name);
} }
syncUrlParam("player", name);
}
/**
* Set the active session ID and sync to URL. Also resolves the human-readable
* session name from the cached manifest.
*/
export function setSessionId(id: string | null): void {
setState("sessionId", id);
if (typeof localStorage !== "undefined" && id) {
localStorage.setItem(LS_LAST_SESSION, id);
}
if (id) {
syncUrlParam("session", id);
// Resolve session name from current manifest
const manifest = sessionList();
const name = manifest.sessions[id]?.name ?? null;
setState("sessionName", name);
} else {
removeUrlParam("session");
setState("sessionName", null);
}
}
// ---------------------------------------------------------------------------
// URL param sync (session & player for bookmarkable links)
// ---------------------------------------------------------------------------
function syncUrlParam(key: string, value: string): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set(key, value);
window.history.replaceState(null, "", url.toString());
}
function removeUrlParam(key: string): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.delete(key);
window.history.replaceState(null, "", url.toString());
}
/**
* Read initial session/player from URL search params on store init.
* Called once at module load time.
*/
function readUrlParams(): {
sessionId: string | null;
playerName: string | null;
} {
if (typeof window === "undefined")
return { sessionId: null, playerName: null };
const params = new URL(window.location.href).searchParams;
return {
sessionId: params.get("session"),
playerName: params.get("player"),
};
} }
// Will hold the MQTT client instance after connect() // Will hold the MQTT client instance after connect()
@ -253,6 +323,11 @@ export async function connectStream(
try { try {
const manifest: SessionManifest = JSON.parse(raw); const manifest: SessionManifest = JSON.parse(raw);
setSessionList(manifest); setSessionList(manifest);
// Refresh the sessionName if we're in a session now
const currentId = state.sessionId;
if (currentId && manifest.sessions[currentId]) {
setState("sessionName", manifest.sessions[currentId].name);
}
} catch (e) { } catch (e) {
console.error("[stream] manifest parse err:", e); console.error("[stream] manifest parse err:", e);
} }