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:
parent
6b5fcdc051
commit
13f454de9c
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue