feat(journal): implement journal UI components
Introduce a complete set of journal components including: - JournalPanel: main container with mobile/desktop support - ConnectionBar: MQTT connection and session management - StreamView and StreamMessage: message log and individual message cards - ComposePanel: message composition with type selection - DynamicForm: recursive form generator based on Zod schemas
This commit is contained in:
parent
8194438816
commit
53c33587fb
15
src/App.tsx
15
src/App.tsx
|
|
@ -11,12 +11,14 @@ import {
|
|||
DataSourceDialog,
|
||||
} from "./components";
|
||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||
import { JournalPanel } from "./components/journal";
|
||||
|
||||
const App: Component = () => {
|
||||
const location = useLocation();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
||||
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
||||
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
||||
const [isJournalOpen, setIsJournalOpen] = createSignal(false);
|
||||
|
||||
// Sidebar and TOC data — reload when source changes
|
||||
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||
|
|
@ -75,6 +77,15 @@ const App: Component = () => {
|
|||
</button>
|
||||
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
|
||||
<div class="flex-1" />
|
||||
<button
|
||||
onClick={() => setIsJournalOpen((v) => !v)}
|
||||
class={`text-sm px-2 py-1 rounded hover:bg-gray-100 ${
|
||||
isJournalOpen() ? "text-blue-600 font-medium" : "text-gray-500"
|
||||
}`}
|
||||
title="Journal Stream"
|
||||
>
|
||||
📋 Journal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsDataSourceOpen(true)}
|
||||
class="text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-100"
|
||||
|
|
@ -106,6 +117,10 @@ const App: Component = () => {
|
|||
onClose={() => setIsDataSourceOpen(false)}
|
||||
onSourceChanged={handleSourceChanged}
|
||||
/>
|
||||
<JournalPanel
|
||||
open={isJournalOpen()}
|
||||
onClose={() => setIsJournalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* ComposePanel — type picker + dynamic form + send button
|
||||
*/
|
||||
|
||||
import { Component, createSignal, For, Show, createMemo } from "solid-js";
|
||||
import { registeredTypes, canEmit } from "./registry";
|
||||
import {
|
||||
sendMessage,
|
||||
revertLatest,
|
||||
canRevert,
|
||||
useJournalStream,
|
||||
} from "../stores/journalStream";
|
||||
import type { MessageTypeDef } from "./registry";
|
||||
import { DynamicForm } from "./DynamicForm";
|
||||
|
||||
export const ComposePanel: Component = () => {
|
||||
const stream = useJournalStream();
|
||||
const [selectedType, setSelectedType] = createSignal<string>("narrative");
|
||||
const [formData, setFormData] = createSignal<Record<string, unknown>>({});
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [sending, setSending] = createSignal(false);
|
||||
|
||||
const role = () => stream.myName;
|
||||
|
||||
// Available types filtered by role
|
||||
const availableTypes = createMemo(() => {
|
||||
const types: string[] = [];
|
||||
for (const type of registeredTypes()) {
|
||||
if (canEmit(type, role())) {
|
||||
types.push(type);
|
||||
}
|
||||
}
|
||||
// Default to narrative if available, otherwise first
|
||||
if (types.includes("narrative")) {
|
||||
return types;
|
||||
}
|
||||
return types;
|
||||
});
|
||||
|
||||
const handleSend = () => {
|
||||
setError(null);
|
||||
setSending(true);
|
||||
|
||||
const result = sendMessage(selectedType(), formData());
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
} else {
|
||||
setFormData({}); // Reset on success
|
||||
}
|
||||
|
||||
setSending(false);
|
||||
};
|
||||
|
||||
const handleRevert = () => {
|
||||
revertLatest();
|
||||
};
|
||||
|
||||
const showRevert = () => canRevert() && stream.myName === "gm";
|
||||
|
||||
return (
|
||||
<div class="border-t border-gray-200 p-2 space-y-2 bg-white">
|
||||
{/* Type picker */}
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedType()}
|
||||
onChange={(e) => {
|
||||
setSelectedType(e.currentTarget.value);
|
||||
setFormData({});
|
||||
}}
|
||||
class="text-xs border border-gray-300 rounded px-2 py-1 bg-white"
|
||||
>
|
||||
<For each={availableTypes()}>
|
||||
{(type) => <option value={type}>{type}</option>}
|
||||
</For>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={sending()}
|
||||
class="ml-auto bg-blue-600 text-white rounded px-3 py-1 text-xs hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{sending() ? "..." : "Send"}
|
||||
</button>
|
||||
|
||||
<Show when={showRevert()}>
|
||||
<button
|
||||
onClick={handleRevert}
|
||||
class="text-xs text-gray-400 hover:text-red-500"
|
||||
title="Revert last message"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Dynamic form */}
|
||||
<DynamicForm
|
||||
type={selectedType()}
|
||||
value={formData()}
|
||||
onChange={setFormData}
|
||||
/>
|
||||
|
||||
<Show when={error()}>
|
||||
<p class="text-red-500 text-xs">{error()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
/**
|
||||
* ConnectionBar — connect form, session picker, player name
|
||||
*
|
||||
* Three states: disconnected → connecting → connected
|
||||
*/
|
||||
|
||||
import { Component, createSignal, Show, For, onMount } from "solid-js";
|
||||
import {
|
||||
connectStream,
|
||||
hydrateFromServer,
|
||||
useJournalStream,
|
||||
sessions,
|
||||
setMyName,
|
||||
createSession,
|
||||
deleteSession,
|
||||
} from "../stores/journalStream";
|
||||
import type { SessionMeta } from "../stores/journalStream";
|
||||
import { createMemo } from "solid-js";
|
||||
|
||||
// Direct access to the store's setter for session switching
|
||||
import { journalSetState } from "../stores/journalStream";
|
||||
|
||||
export const ConnectionBar: Component = () => {
|
||||
const stream = useJournalStream();
|
||||
const [brokerUrl, setBrokerUrl] = createSignal("");
|
||||
const [newSessionName, setNewSessionName] = createSignal("");
|
||||
const [playerName, setPlayerName] = createSignal("");
|
||||
const [connecting, setConnecting] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [showNewSession, setShowNewSession] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
setBrokerUrl(stream.brokerUrl ?? "");
|
||||
setPlayerName(stream.myName);
|
||||
});
|
||||
|
||||
const manifest = sessions;
|
||||
|
||||
const handleConnect = async () => {
|
||||
const url = brokerUrl().trim();
|
||||
if (!url) return;
|
||||
|
||||
setConnecting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Save name first
|
||||
if (playerName().trim()) {
|
||||
setMyName(playerName().trim());
|
||||
}
|
||||
|
||||
// Connect
|
||||
const sessionId = stream.sessionId || "default";
|
||||
await hydrateFromServer(sessionId);
|
||||
await connectStream(sessionId, url);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Connection failed");
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSession = () => {
|
||||
const name = newSessionName().trim();
|
||||
if (!name) return;
|
||||
createSession(name);
|
||||
setNewSessionName("");
|
||||
setShowNewSession(false);
|
||||
};
|
||||
|
||||
const handleDeleteSession = (id: string) => {
|
||||
if (confirm(`Delete session "${id}"? This cannot be undone.`)) {
|
||||
deleteSession(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSessionSelect = async (id: string) => {
|
||||
if (!stream.connected) return;
|
||||
try {
|
||||
await hydrateFromServer(id);
|
||||
journalSetState("sessionId", id);
|
||||
} catch (e) {
|
||||
setError("Failed to load session");
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor = () =>
|
||||
stream.connected
|
||||
? "bg-green-500"
|
||||
: connecting()
|
||||
? "bg-yellow-400"
|
||||
: "bg-gray-400";
|
||||
|
||||
return (
|
||||
<div class="border-b border-gray-200 p-3 space-y-2 text-sm">
|
||||
{/* Status + session name */}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`w-2 h-2 rounded-full ${statusColor()}`} />
|
||||
<span class="text-xs text-gray-500">
|
||||
{stream.connected
|
||||
? "connected"
|
||||
: connecting()
|
||||
? "connecting..."
|
||||
: "disconnected"}
|
||||
</span>
|
||||
<Show when={stream.sessionId}>
|
||||
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{stream.sessionId}
|
||||
</span>
|
||||
</Show>
|
||||
<div class="flex-1" />
|
||||
<Show when={stream.connected}>
|
||||
<button
|
||||
onClick={() => {} /* disconnect */}
|
||||
class="text-xs text-gray-400 hover:text-red-500"
|
||||
title="Disconnect"
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Disconnected: connect form */}
|
||||
<Show when={!stream.connected}>
|
||||
<div class="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={brokerUrl()}
|
||||
onInput={(e) => setBrokerUrl(e.currentTarget.value)}
|
||||
placeholder="MQTT broker (e.g. tcp://192.168.1.5:1883)"
|
||||
class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={playerName()}
|
||||
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
||||
placeholder="Your name (GM or player)"
|
||||
class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting() || !brokerUrl().trim()}
|
||||
class="w-full bg-blue-600 text-white rounded px-3 py-1 text-xs hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{connecting() ? "Connecting..." : "Connect"}
|
||||
</button>
|
||||
<Show when={error()}>
|
||||
<p class="text-red-500 text-xs">{error()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Connected: session management (GM only) */}
|
||||
<Show when={stream.connected && stream.myName === "gm"}>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-gray-500">Sessions:</span>
|
||||
<button
|
||||
onClick={() => setShowNewSession((v) => !v)}
|
||||
class="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
+ new
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={showNewSession()}>
|
||||
<div class="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-2 py-0.5 text-xs"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreateSession()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateSession}
|
||||
class="bg-green-600 text-white rounded px-2 py-0.5 text-xs hover:bg-green-700"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={Object.entries(manifest().sessions)}>
|
||||
{([id, meta]) => (
|
||||
<div
|
||||
class={`flex items-center gap-1 px-1 py-0.5 rounded cursor-pointer text-xs ${
|
||||
id === stream.sessionId
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class="flex-1 truncate"
|
||||
onClick={() => handleSessionSelect(id)}
|
||||
>
|
||||
{meta.name || id}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteSession(id);
|
||||
}}
|
||||
class="text-gray-400 hover:text-red-500 text-xs"
|
||||
title="Delete session"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Connected: player info (player only) */}
|
||||
<Show when={stream.connected && stream.myName !== "gm"}>
|
||||
<div class="flex items-center gap-1 text-xs text-gray-500">
|
||||
<span>Playing as:</span>
|
||||
<span class="font-medium text-gray-700">{stream.myName}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
/**
|
||||
* DynamicForm — renders form inputs from a Zod schema
|
||||
*
|
||||
* Walks the schema's shape and renders appropriate inputs for each field.
|
||||
* Supports: string, number, boolean, enum, array, and nested objects.
|
||||
* Optional fields get a checkbox to toggle inclusion.
|
||||
*/
|
||||
|
||||
import { Component, For, Show, createMemo } from "solid-js";
|
||||
import { z } from "zod";
|
||||
import { getMessageType } from "./registry";
|
||||
import type { MessageTypeDef } from "./registry";
|
||||
|
||||
type FormData = Record<string, unknown>;
|
||||
|
||||
interface DynamicFormProps {
|
||||
type: string;
|
||||
value: FormData;
|
||||
onChange: (data: FormData) => void;
|
||||
}
|
||||
|
||||
export const DynamicForm: Component<DynamicFormProps> = (props) => {
|
||||
const def = createMemo(() => getMessageType(props.type));
|
||||
|
||||
// Generate default on type change
|
||||
const schema = createMemo(() => def()?.schema);
|
||||
|
||||
return (
|
||||
<div class="space-y-1.5">
|
||||
<Show when={def()}>
|
||||
{(d) => (
|
||||
<SchemaFields
|
||||
schema={d().schema}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SchemaFields — recursive
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SchemaFields: Component<{
|
||||
schema: z.ZodTypeAny;
|
||||
value: FormData;
|
||||
onChange: (data: FormData) => void;
|
||||
prefix?: string;
|
||||
}> = (props) => {
|
||||
const shape = unwrapShape(props.schema);
|
||||
|
||||
if (!shape) {
|
||||
return <p class="text-xs text-gray-400">No fields</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<For each={Object.entries(shape)}>
|
||||
{([key, fieldSchema]) => {
|
||||
const def = (fieldSchema as z.ZodDefault<any>)._def;
|
||||
const isOptional = fieldSchema.isOptional?.() ?? false;
|
||||
const fieldType = getFieldType(fieldSchema);
|
||||
|
||||
// Nested object
|
||||
if (fieldType === "object" && fieldSchema instanceof z.ZodObject) {
|
||||
const nestedValue = (props.value[key] as FormData) ?? {};
|
||||
return (
|
||||
<fieldset class="border border-gray-200 rounded px-2 py-1">
|
||||
<legend class="text-xs text-gray-500 px-1">{key}</legend>
|
||||
<SchemaFields
|
||||
schema={fieldSchema}
|
||||
value={nestedValue}
|
||||
onChange={(v) => updateField(props, key, v)}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
// Optional toggle
|
||||
const isSet = () => props.value[key] !== undefined;
|
||||
const toggleOptional = () => {
|
||||
if (isSet()) {
|
||||
const next = { ...props.value };
|
||||
delete next[key];
|
||||
props.onChange(next);
|
||||
} else {
|
||||
props.onChange({ ...props.value, [key]: getDefault(fieldSchema) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex items-start gap-1.5">
|
||||
<Show when={isOptional}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSet()}
|
||||
onChange={toggleOptional}
|
||||
class="mt-1.5"
|
||||
title={`Include ${key}`}
|
||||
/>
|
||||
</Show>
|
||||
<div class={`flex-1 ${isOptional && !isSet() ? "opacity-40" : ""}`}>
|
||||
<FieldInput
|
||||
label={key}
|
||||
schema={fieldSchema}
|
||||
value={isSet() ? props.value[key] : getDefault(fieldSchema)}
|
||||
onChange={(v) => updateField(props, key, v)}
|
||||
disabled={isOptional && !isSet()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FieldInput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FieldInput: Component<{
|
||||
label: string;
|
||||
schema: z.ZodTypeAny;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
disabled?: boolean;
|
||||
}> = (props) => {
|
||||
const fieldType = createMemo(() => getFieldType(props.schema));
|
||||
|
||||
const inputClass =
|
||||
"w-full border border-gray-300 rounded px-2 py-1 text-xs disabled:bg-gray-100 disabled:text-gray-400";
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-1">
|
||||
<label
|
||||
class="text-xs text-gray-500 w-20 flex-shrink-0 truncate"
|
||||
title={props.label}
|
||||
>
|
||||
{props.label}
|
||||
</label>
|
||||
|
||||
<Show when={fieldType() === "string"}>
|
||||
<input
|
||||
type="text"
|
||||
value={(props.value as string) ?? ""}
|
||||
onInput={(e) => props.onChange(e.currentTarget.value)}
|
||||
disabled={props.disabled}
|
||||
class={inputClass}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={fieldType() === "number"}>
|
||||
<input
|
||||
type="number"
|
||||
value={(props.value as number) ?? ""}
|
||||
onInput={(e) =>
|
||||
props.onChange(parseFloat(e.currentTarget.value) || 0)
|
||||
}
|
||||
disabled={props.disabled}
|
||||
class={inputClass}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={fieldType() === "boolean"}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(props.value as boolean) ?? false}
|
||||
onChange={(e) => props.onChange(e.currentTarget.checked)}
|
||||
disabled={props.disabled}
|
||||
class="mt-1"
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={fieldType() === "enum"}>
|
||||
<select
|
||||
value={(props.value as string) ?? ""}
|
||||
onChange={(e) => props.onChange(e.currentTarget.value)}
|
||||
disabled={props.disabled}
|
||||
class={inputClass}
|
||||
>
|
||||
<For each={getEnumValues(props.schema)}>
|
||||
{(v) => <option value={v}>{v}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</Show>
|
||||
|
||||
<Show when={fieldType() === "array"}>
|
||||
<input
|
||||
type="text"
|
||||
value={
|
||||
Array.isArray(props.value) ? (props.value as any[]).join(", ") : ""
|
||||
}
|
||||
onInput={(e) => {
|
||||
const raw = e.currentTarget.value;
|
||||
const parsed = raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => (isNaN(Number(s)) ? s : Number(s)));
|
||||
props.onChange(parsed);
|
||||
}}
|
||||
disabled={props.disabled}
|
||||
class={inputClass}
|
||||
placeholder="comma-separated"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function unwrapShape(
|
||||
schema: z.ZodTypeAny,
|
||||
): Record<string, z.ZodTypeAny> | null {
|
||||
try {
|
||||
if (schema instanceof z.ZodObject) {
|
||||
return schema.shape as Record<string, z.ZodTypeAny>;
|
||||
}
|
||||
// Unwrap optional/branded/etc
|
||||
const inner = (schema as any)?._def?.innerType;
|
||||
if (inner) return unwrapShape(inner);
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type FieldType =
|
||||
"string" | "number" | "boolean" | "enum" | "array" | "object" | "unknown";
|
||||
|
||||
function getFieldType(schema: z.ZodTypeAny): FieldType {
|
||||
if (schema instanceof z.ZodString) return "string";
|
||||
if (schema instanceof z.ZodNumber) return "number";
|
||||
if (schema instanceof z.ZodBoolean) return "boolean";
|
||||
if (schema instanceof z.ZodEnum) return "enum";
|
||||
// Zod v4: nativeEnum returns ZodEnum, catch with duck-type check
|
||||
if (isZodEnum(schema)) return "enum";
|
||||
if (schema instanceof z.ZodArray) return "array";
|
||||
if (schema instanceof z.ZodObject) return "object";
|
||||
// Unwrap optional
|
||||
const inner = (schema as any)?._def?.innerType;
|
||||
if (inner) return getFieldType(inner);
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** Duck-type check: Zod v4's nativeEnum returns a ZodEnum instance */
|
||||
function isZodEnum(schema: z.ZodTypeAny): boolean {
|
||||
return (schema as any)?._def?.typeName === "ZodEnum";
|
||||
}
|
||||
|
||||
function getEnumValues(schema: z.ZodTypeAny): string[] {
|
||||
try {
|
||||
if (schema instanceof z.ZodEnum) return schema.options as string[];
|
||||
if (isZodEnum(schema) && (schema as any).enum) {
|
||||
return Object.values((schema as any).enum).filter(
|
||||
(v: unknown) => typeof v === "string",
|
||||
) as string[];
|
||||
}
|
||||
const inner = (schema as any)?._def?.innerType;
|
||||
if (inner) return getEnumValues(inner);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getDefault(schema: z.ZodTypeAny): unknown {
|
||||
const ft = getFieldType(schema);
|
||||
switch (ft) {
|
||||
case "string":
|
||||
return "";
|
||||
case "number":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return false;
|
||||
case "array":
|
||||
return [];
|
||||
case "object":
|
||||
return {};
|
||||
case "enum":
|
||||
return getEnumValues(schema)[0] ?? "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function updateField(
|
||||
parent: { value: FormData; onChange: (data: FormData) => void },
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
parent.onChange({ ...parent.value, [key]: value });
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* JournalPanel — right panel container for the journal stream
|
||||
*
|
||||
* Collapsible, overlays on mobile, pushes content on desktop.
|
||||
*/
|
||||
|
||||
import { Component, Show, createSignal } from "solid-js";
|
||||
import { useJournalStream } from "../stores/journalStream";
|
||||
import { ConnectionBar } from "./ConnectionBar";
|
||||
import { StreamView } from "./StreamView";
|
||||
import { ComposePanel } from "./ComposePanel";
|
||||
|
||||
export interface JournalPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||
const stream = useJournalStream();
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
{/* Mobile overlay */}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/30 z-40 md:hidden"
|
||||
onClick={props.onClose}
|
||||
/>
|
||||
<aside
|
||||
class={`
|
||||
fixed top-16 right-0 bottom-0 z-50 bg-white border-l border-gray-200
|
||||
flex flex-col
|
||||
w-full max-w-md md:w-[420px]
|
||||
md:relative md:top-0 md:z-0
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
||||
<h2 class="text-sm font-semibold text-gray-700">Journal</h2>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ConnectionBar />
|
||||
|
||||
{/* Stream */}
|
||||
<div class="flex-1 min-h-0">
|
||||
<StreamView />
|
||||
</div>
|
||||
|
||||
{/* Compose */}
|
||||
<Show when={stream.connected}>
|
||||
<ComposePanel />
|
||||
</Show>
|
||||
</aside>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* StreamMessage — single message card in the stream
|
||||
*/
|
||||
|
||||
import { Component, Show, createMemo } from "solid-js";
|
||||
import type { StreamMessage as StreamMessageType } from "./registry";
|
||||
import { getMessageType } from "./registry";
|
||||
|
||||
export const StreamMessageCard: Component<{
|
||||
message: StreamMessageType;
|
||||
}> = (props) => {
|
||||
const def = createMemo(() => getMessageType(props.message.type));
|
||||
const rendered = createMemo(() =>
|
||||
def()?.render?.(props.message.payload, props.message as any),
|
||||
);
|
||||
|
||||
const time = createMemo(() => {
|
||||
const d = new Date(props.message.timestamp);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
});
|
||||
|
||||
const senderClass = () =>
|
||||
props.message.sender === "gm"
|
||||
? "text-purple-600 font-semibold"
|
||||
: "text-blue-600 font-semibold";
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!props.message.reverted}
|
||||
fallback={<RevealedCard msg={props.message} />}
|
||||
>
|
||||
<div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
||||
{/* Header */}
|
||||
<div class="flex items-center gap-1.5 px-2.5 py-1 bg-gray-50 border-b border-gray-100">
|
||||
<span class={`text-xs ${senderClass()}`}>{props.message.sender}</span>
|
||||
<span class="text-xs text-gray-400">{time()}</span>
|
||||
<span class="text-xs text-gray-300 bg-gray-100 px-1 rounded ml-auto">
|
||||
{props.message.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div class="px-2.5 py-1.5 text-sm text-gray-800">
|
||||
<Show
|
||||
when={rendered()}
|
||||
fallback={<JsonBody payload={props.message.payload} />}
|
||||
>
|
||||
{rendered()}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
/** Fallback: render payload as formatted JSON */
|
||||
const JsonBody: Component<{ payload: unknown }> = (props) => {
|
||||
const text = () => JSON.stringify(props.payload, null, 2);
|
||||
return (
|
||||
<pre class="text-xs text-gray-600 whitespace-pre-wrap font-mono">
|
||||
{text()}
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
||||
/** Reverted card: struck-through, grey */
|
||||
const RevealedCard: Component<{ msg: StreamMessageType }> = (props) => {
|
||||
return (
|
||||
<div class="bg-gray-100 rounded-lg border border-gray-200 opacity-50 px-2.5 py-1.5">
|
||||
<span class="text-xs text-gray-400 line-through">
|
||||
{props.msg.sender} — reverted
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* StreamView — scrollable message log
|
||||
*/
|
||||
|
||||
import { Component, For, createEffect, onMount } from "solid-js";
|
||||
import { visibleMessages, useJournalStream } from "../stores/journalStream";
|
||||
import { StreamMessageCard } from "./StreamMessage";
|
||||
|
||||
export const StreamView: Component = () => {
|
||||
const stream = useJournalStream();
|
||||
let containerRef: HTMLDivElement | undefined;
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
const messages = () => visibleMessages();
|
||||
|
||||
createEffect(() => {
|
||||
void messages().length; // track
|
||||
if (!containerRef) return;
|
||||
containerRef.scrollTop = containerRef.scrollHeight;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="h-full overflow-y-auto px-3 py-2 space-y-2 bg-gray-50"
|
||||
>
|
||||
<For each={messages()}>
|
||||
{(msg) => <StreamMessageCard message={msg} />}
|
||||
</For>
|
||||
<Show when={messages().length === 0}>
|
||||
<p class="text-center text-gray-400 text-xs py-8">
|
||||
{stream.connected
|
||||
? "No messages yet. Start by posting something."
|
||||
: "Connect to a session to see messages."}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
import { Show } from "solid-js";
|
||||
|
|
@ -33,3 +33,12 @@ export type {
|
|||
SessionMeta,
|
||||
SessionManifest,
|
||||
} from "../stores/journalStream";
|
||||
|
||||
// UI components
|
||||
export { JournalPanel } from "./JournalPanel";
|
||||
export type { JournalPanelProps } from "./JournalPanel";
|
||||
export { ConnectionBar } from "./ConnectionBar";
|
||||
export { StreamView } from "./StreamView";
|
||||
export { StreamMessageCard } from "./StreamMessage";
|
||||
export { ComposePanel } from "./ComposePanel";
|
||||
export { DynamicForm } from "./DynamicForm";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
/**
|
||||
* Built-in message type: article.reveal
|
||||
*
|
||||
* GM reveals a document/article path to players. The reducer populates
|
||||
* the revealedPaths set so the Article component can gate visibility.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
|
@ -11,11 +8,8 @@ import { journalSetState } from "../../stores/journalStream";
|
|||
import { produce } from "solid-js/store";
|
||||
|
||||
const schema = z.object({
|
||||
/** Path relative to content root, e.g. "/adventures/dungeon.md" */
|
||||
path: z.string().min(1),
|
||||
/** Optional display label, defaults to the path */
|
||||
label: z.string().optional(),
|
||||
/** Optional section heading to deep-link */
|
||||
section: z.string().optional(),
|
||||
});
|
||||
|
||||
|
|
@ -27,6 +21,19 @@ registerMessageType<ArticleRevealPayload>({
|
|||
emitters: ["gm"],
|
||||
schema,
|
||||
defaultPayload: () => ({ path: "/" }),
|
||||
render: (p) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg">📄</span>
|
||||
<div>
|
||||
<a href={p.path} class="text-blue-600 underline text-sm">
|
||||
{p.label || p.path}
|
||||
</a>
|
||||
{p.section && (
|
||||
<span class="text-xs text-gray-400 ml-1">§ {p.section}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
reducer: (p) => {
|
||||
journalSetState(
|
||||
produce((s) => {
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
/**
|
||||
* Built-in message types: intent / resolution
|
||||
*
|
||||
* intent — Player declares an action or resource usage for GM to resolve
|
||||
* resolution — GM resolves a player intent (outcome, stat changes, etc.)
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
|
@ -13,12 +10,7 @@ import { registerMessageType } from "../registry";
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const intentSchema = z.object({
|
||||
/** Plain-text description of what the player wants to do */
|
||||
description: z.string().min(1),
|
||||
/**
|
||||
* Optional JSON-serializable context (item name, skill name, target, etc.)
|
||||
* Parsed by game-specific widgets.
|
||||
*/
|
||||
context: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
|
|
@ -30,6 +22,20 @@ registerMessageType<IntentPayload>({
|
|||
emitters: ["player"],
|
||||
schema: intentSchema,
|
||||
defaultPayload: () => ({ description: "" }),
|
||||
render: (p) => (
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg">🙋</span>
|
||||
<span class="text-sm font-medium text-yellow-700">Intent</span>
|
||||
</div>
|
||||
<p class="text-sm">{p.description}</p>
|
||||
{p.context && Object.keys(p.context).length > 0 && (
|
||||
<pre class="text-xs bg-gray-100 rounded px-2 py-1 text-gray-600">
|
||||
{JSON.stringify(p.context, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -37,14 +43,8 @@ registerMessageType<IntentPayload>({
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resolutionSchema = z.object({
|
||||
/** The id of the intent message being resolved */
|
||||
intentId: z.string().min(1),
|
||||
/** Freeform outcome text */
|
||||
outcome: z.string().min(1),
|
||||
/**
|
||||
* Stat changes to apply. Game-specific widgets interpret these keys.
|
||||
* Example: { "hp": -3, "stress": 1, "status.add": "bleeding" }
|
||||
*/
|
||||
statChanges: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
|
|
@ -55,4 +55,23 @@ registerMessageType<ResolutionPayload>({
|
|||
label: "Resolve Intent",
|
||||
emitters: ["gm"],
|
||||
schema: resolutionSchema,
|
||||
render: (p) => (
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg">✅</span>
|
||||
<span class="text-sm font-medium text-green-700">Resolution</span>
|
||||
<span class="text-xs text-gray-400">re: {p.intentId}</span>
|
||||
</div>
|
||||
<p class="text-sm">{p.outcome}</p>
|
||||
{p.statChanges && Object.keys(p.statChanges).length > 0 && (
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{Object.entries(p.statChanges).map(([k, v]) => (
|
||||
<span class="text-xs bg-green-50 text-green-800 px-1.5 py-0.5 rounded border border-green-200">
|
||||
{k}: {String(v)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
/**
|
||||
* Built-in message type: narrative
|
||||
*
|
||||
* Freeform text from GM or players. Renders as a chat bubble.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { registerMessageType } from "../registry";
|
||||
|
||||
const schema = z.object({
|
||||
text: z.string().min(1, "Message cannot be empty").max(2000, "Message too long"),
|
||||
text: z
|
||||
.string()
|
||||
.min(1, "Message cannot be empty")
|
||||
.max(2000, "Message too long"),
|
||||
});
|
||||
|
||||
export type NarrativePayload = z.infer<typeof schema>;
|
||||
|
|
@ -18,9 +19,5 @@ registerMessageType<NarrativePayload>({
|
|||
label: "Narrative",
|
||||
schema,
|
||||
defaultPayload: () => ({ text: "" }),
|
||||
render: (p, msg) => {
|
||||
// Placeholder — real render will be in StreamMessage component
|
||||
// Returning a simple representation for now
|
||||
return undefined;
|
||||
},
|
||||
render: (p) => <p class="text-sm whitespace-pre-wrap">{p.text}</p>,
|
||||
});
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
/**
|
||||
* Built-in message types: roll.request / roll.result
|
||||
*
|
||||
* roll.request — GM asks for a roll, renders a prompt with dice roller
|
||||
* roll.result — Player responds with their roll outcome
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
|
@ -13,13 +10,9 @@ import { registerMessageType } from "../registry";
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const rollRequestSchema = z.object({
|
||||
/** Dice notation, e.g. "2d6", "1d20+5", "3d6kh1" */
|
||||
notation: z.string().min(1),
|
||||
/** Human label, e.g. "Fear save", "Attack roll" */
|
||||
label: z.string().min(1),
|
||||
/** Optional target number to compare against */
|
||||
target: z.number().int().optional(),
|
||||
/** Optional extra context */
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
|
|
@ -31,6 +24,19 @@ registerMessageType<RollRequestPayload>({
|
|||
emitters: ["gm"],
|
||||
schema: rollRequestSchema,
|
||||
defaultPayload: () => ({ notation: "1d20", label: "" }),
|
||||
render: (p) => (
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg">🎲</span>
|
||||
<span class="font-bold">{p.notation}</span>
|
||||
<span class="text-gray-600">{p.label}</span>
|
||||
</div>
|
||||
{p.description && <p class="text-xs text-gray-500">{p.description}</p>}
|
||||
{p.target !== undefined && (
|
||||
<p class="text-xs text-gray-500">Target: {p.target}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -38,15 +44,10 @@ registerMessageType<RollRequestPayload>({
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const rollResultSchema = z.object({
|
||||
/** The dice notation that was rolled */
|
||||
notation: z.string().min(1),
|
||||
/** The raw dice results, e.g. [6, 4] for 2d6 */
|
||||
dice: z.array(z.number().int().min(1)),
|
||||
/** Modifiers, e.g. +5 */
|
||||
modifier: z.number().int().default(0),
|
||||
/** Final total after modifiers and keep/drop rules */
|
||||
total: z.number(),
|
||||
/** Optional: which roll.request message this is responding to (by id) */
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
|
||||
|
|
@ -57,4 +58,24 @@ registerMessageType<RollResultPayload>({
|
|||
label: "Roll Result",
|
||||
emitters: ["player"],
|
||||
schema: rollResultSchema,
|
||||
render: (p) => {
|
||||
const diceStr = p.dice.join(", ");
|
||||
const modStr =
|
||||
p.modifier !== 0
|
||||
? p.modifier > 0
|
||||
? ` + ${p.modifier}`
|
||||
: ` - ${Math.abs(p.modifier)}`
|
||||
: "";
|
||||
return (
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-lg">🎲</span>
|
||||
<span class="font-mono text-sm">
|
||||
{p.notation} → [{diceStr}]{modStr} ={" "}
|
||||
<span class="font-bold text-base">{p.total}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue