refactor: extract JournalHeader from JournalPanel

Extract the header logic into a standalone `JournalHeader` component.
This new component manages the connection status display, session
dropdown for switching between sessions, and the ability to create
new sessions or disconnect.
This commit is contained in:
hypercross 2026-07-06 16:22:06 +08:00
parent 13f454de9c
commit 307a3c8320
1 changed files with 168 additions and 42 deletions

View File

@ -5,15 +5,17 @@
* Auto-connects to ws://{current host}:{current port} — no URL input needed.
*/
import { Component, Show, createSignal, onMount } from "solid-js";
import { Component, Show, createSignal, onMount, For } from "solid-js";
import {
connectStream,
hydrateFromServer,
useJournalStream,
setMyName,
setSessionId,
sessions,
createSession,
disconnectStream,
} from "../stores/journalStream";
import { ConnectionBar } from "./ConnectionBar";
import { StreamView } from "./StreamView";
import { ComposePanel } from "./ComposePanel";
@ -43,45 +45,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
aria-hidden={!props.open}
>
{/* Header */}
<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 min-w-0">
<span
class="w-2 h-2 rounded-full shrink-0"
classList={{
"bg-gray-400": stream.connectionStatus === "disconnected",
"bg-yellow-400 animate-pulse":
stream.connectionStatus === "connecting",
"bg-green-500": stream.connectionStatus === "connected",
"bg-red-500": stream.connectionStatus === "error",
}}
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>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm px-1 shrink-0"
title="Hide panel"
>
</button>
</div>
<JournalHeader onClose={props.onClose} />
<Show
when={stream.connected}
@ -91,7 +55,6 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
</div>
}
>
<ConnectionBar />
<div class="flex-1 min-h-0">
<StreamView />
</div>
@ -102,6 +65,169 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
);
};
// ---------------------------------------------------------------------------
// Journal Header — status dot, player name, session dropdown, disconnect & close
// ---------------------------------------------------------------------------
interface JournalHeaderProps {
onClose: () => void;
}
const JournalHeader: Component<JournalHeaderProps> = (props) => {
const stream = useJournalStream();
const manifest = sessions;
const [dropdownOpen, setDropdownOpen] = createSignal(false);
const [newSessionName, setNewSessionName] = createSignal("");
const [showNewInput, setShowNewInput] = createSignal(false);
let dropdownRef!: HTMLDivElement;
const handleSessionSelect = async (id: string) => {
try {
await hydrateFromServer(id);
setSessionId(id);
} catch {
/* */
}
setDropdownOpen(false);
};
const handleCreate = () => {
const name = newSessionName().trim();
if (!name) return;
createSession(name);
setNewSessionName("");
setShowNewInput(false);
};
const handleDisconnect = () => disconnectStream();
// Close dropdown on outside click
if (typeof document !== "undefined") {
document.addEventListener("click", (e) => {
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
setDropdownOpen(false);
setShowNewInput(false);
}
});
}
return (
<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 min-w-0">
<span
class="w-2 h-2 rounded-full shrink-0"
classList={{
"bg-gray-400": stream.connectionStatus === "disconnected",
"bg-yellow-400 animate-pulse":
stream.connectionStatus === "connecting",
"bg-green-500": stream.connectionStatus === "connected",
"bg-red-500": stream.connectionStatus === "error",
}}
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>
{/* Clickable subtitle — session dropdown */}
<div ref={dropdownRef} class="relative">
<button
onClick={() => setDropdownOpen((v) => !v)}
class="text-[10px] text-gray-400 hover:text-gray-600 truncate leading-tight cursor-pointer text-left"
>
{stream.sessionName || stream.sessionId}{" "}
<span class="text-gray-300"></span>
</button>
<Show when={dropdownOpen()}>
<div class="absolute top-full left-0 mt-1 w-48 bg-white border border-gray-200 rounded shadow-lg z-50 py-1 max-h-48 overflow-y-auto">
<For each={Object.entries(manifest().sessions)}>
{([id, meta]) => (
<button
onClick={() => handleSessionSelect(id)}
class={`w-full text-left px-3 py-1 text-xs hover:bg-gray-100 flex items-center gap-1 ${
id === stream.sessionId
? "bg-blue-50 text-blue-700"
: "text-gray-700"
}`}
>
<span class="truncate flex-1">{meta.name || id}</span>
<Show when={id === stream.sessionId}>
<span class="text-blue-500 shrink-0"></span>
</Show>
</button>
)}
</For>
<div class="border-t border-gray-100 my-0.5" />
<Show
when={!showNewInput()}
fallback={
<div class="px-3 py-1 flex gap-1">
<input
type="text"
value={newSessionName()}
onInput={(e) =>
setNewSessionName(e.currentTarget.value)
}
placeholder="Session name"
class="flex-1 border border-gray-300 rounded px-1.5 py-0.5 text-xs"
autofocus
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
if (e.key === "Escape") setShowNewInput(false);
}}
/>
<button
onClick={handleCreate}
class="bg-blue-600 text-white rounded px-2 py-0.5 text-xs hover:bg-blue-700"
>
+
</button>
</div>
}
>
<button
onClick={() => setShowNewInput(true)}
class="w-full text-left px-3 py-1 text-xs text-blue-600 hover:bg-gray-100"
>
+ New session
</button>
</Show>
</div>
</Show>
</div>
</div>
</Show>
</div>
<div class="flex items-center gap-1 shrink-0">
<Show when={stream.connected}>
<button
onClick={handleDisconnect}
class="text-xs text-gray-400 hover:text-red-500 px-1"
title="Disconnect"
>
</button>
</Show>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm px-1"
title="Hide panel"
>
</button>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Connect dialog — one input: player name. Broker URL is always current host.
// ---------------------------------------------------------------------------