diff --git a/src/App.tsx b/src/App.tsx index 4b30fc2..34ed5bf 100644 --- a/src/App.tsx +++ b/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([]); @@ -75,6 +77,15 @@ const App: Component = () => {

TTRPG Tools

+
); }; diff --git a/src/components/journal/ComposePanel.tsx b/src/components/journal/ComposePanel.tsx new file mode 100644 index 0000000..c638523 --- /dev/null +++ b/src/components/journal/ComposePanel.tsx @@ -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("narrative"); + const [formData, setFormData] = createSignal>({}); + const [error, setError] = createSignal(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 ( +
+ {/* Type picker */} +
+ + + + + + + +
+ + {/* Dynamic form */} + + + +

{error()}

+
+
+ ); +}; diff --git a/src/components/journal/ConnectionBar.tsx b/src/components/journal/ConnectionBar.tsx new file mode 100644 index 0000000..584dcff --- /dev/null +++ b/src/components/journal/ConnectionBar.tsx @@ -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(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 ( +
+ {/* Status + session name */} +
+ + + {stream.connected + ? "connected" + : connecting() + ? "connecting..." + : "disconnected"} + + + + {stream.sessionId} + + +
+ + + +
+ + {/* Disconnected: connect form */} + +
+ 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" + /> + setPlayerName(e.currentTarget.value)} + placeholder="Your name (GM or player)" + class="w-full border border-gray-300 rounded px-2 py-1 text-xs" + /> + + +

{error()}

+
+
+
+ + {/* Connected: session management (GM only) */} + +
+
+ Sessions: + +
+ + +
+ 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()} + /> + +
+
+ + + {([id, meta]) => ( +
+ handleSessionSelect(id)} + > + {meta.name || id} + + +
+ )} +
+
+
+ + {/* Connected: player info (player only) */} + +
+ Playing as: + {stream.myName} +
+
+
+ ); +}; diff --git a/src/components/journal/DynamicForm.tsx b/src/components/journal/DynamicForm.tsx new file mode 100644 index 0000000..5ece0c5 --- /dev/null +++ b/src/components/journal/DynamicForm.tsx @@ -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; + +interface DynamicFormProps { + type: string; + value: FormData; + onChange: (data: FormData) => void; +} + +export const DynamicForm: Component = (props) => { + const def = createMemo(() => getMessageType(props.type)); + + // Generate default on type change + const schema = createMemo(() => def()?.schema); + + return ( +
+ + {(d) => ( + + )} + +
+ ); +}; + +// --------------------------------------------------------------------------- +// 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

No fields

; + } + + return ( + + {([key, fieldSchema]) => { + const def = (fieldSchema as z.ZodDefault)._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 ( +
+ {key} + updateField(props, key, v)} + /> +
+ ); + } + + // 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 ( +
+ + + +
+ updateField(props, key, v)} + disabled={isOptional && !isSet()} + /> +
+
+ ); + }} +
+ ); +}; + +// --------------------------------------------------------------------------- +// 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 ( +
+ + + + props.onChange(e.currentTarget.value)} + disabled={props.disabled} + class={inputClass} + /> + + + + + props.onChange(parseFloat(e.currentTarget.value) || 0) + } + disabled={props.disabled} + class={inputClass} + /> + + + + props.onChange(e.currentTarget.checked)} + disabled={props.disabled} + class="mt-1" + /> + + + + + + + + { + 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" + /> + +
+ ); +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function unwrapShape( + schema: z.ZodTypeAny, +): Record | null { + try { + if (schema instanceof z.ZodObject) { + return schema.shape as Record; + } + // 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 }); +} diff --git a/src/components/journal/JournalPanel.tsx b/src/components/journal/JournalPanel.tsx new file mode 100644 index 0000000..1787711 --- /dev/null +++ b/src/components/journal/JournalPanel.tsx @@ -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 = (props) => { + const stream = useJournalStream(); + + return ( + + {/* Mobile overlay */} +
+ + + ); +}; diff --git a/src/components/journal/StreamMessage.tsx b/src/components/journal/StreamMessage.tsx new file mode 100644 index 0000000..66a6490 --- /dev/null +++ b/src/components/journal/StreamMessage.tsx @@ -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 ( + } + > +
+ {/* Header */} +
+ {props.message.sender} + {time()} + + {props.message.type} + +
+ + {/* Body */} +
+ } + > + {rendered()} + +
+
+
+ ); +}; + +/** Fallback: render payload as formatted JSON */ +const JsonBody: Component<{ payload: unknown }> = (props) => { + const text = () => JSON.stringify(props.payload, null, 2); + return ( +
+      {text()}
+    
+ ); +}; + +/** Reverted card: struck-through, grey */ +const RevealedCard: Component<{ msg: StreamMessageType }> = (props) => { + return ( +
+ + {props.msg.sender} โ€” reverted + +
+ ); +}; diff --git a/src/components/journal/StreamView.tsx b/src/components/journal/StreamView.tsx new file mode 100644 index 0000000..37866ac --- /dev/null +++ b/src/components/journal/StreamView.tsx @@ -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 ( +
+ + {(msg) => } + + +

+ {stream.connected + ? "No messages yet. Start by posting something." + : "Connect to a session to see messages."} +

+
+
+ ); +}; + +import { Show } from "solid-js"; diff --git a/src/components/journal/index.ts b/src/components/journal/index.ts index c1737f3..b2716e9 100644 --- a/src/components/journal/index.ts +++ b/src/components/journal/index.ts @@ -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"; diff --git a/src/components/journal/types/article.ts b/src/components/journal/types/article.tsx similarity index 66% rename from src/components/journal/types/article.ts rename to src/components/journal/types/article.tsx index b6a1d62..953ab9a 100644 --- a/src/components/journal/types/article.ts +++ b/src/components/journal/types/article.tsx @@ -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({ emitters: ["gm"], schema, defaultPayload: () => ({ path: "/" }), + render: (p) => ( +
+ ๐Ÿ“„ +
+ + {p.label || p.path} + + {p.section && ( + ยง {p.section} + )} +
+
+ ), reducer: (p) => { journalSetState( produce((s) => { diff --git a/src/components/journal/types/intent.ts b/src/components/journal/types/intent.tsx similarity index 50% rename from src/components/journal/types/intent.ts rename to src/components/journal/types/intent.tsx index 15772d2..23f636a 100644 --- a/src/components/journal/types/intent.ts +++ b/src/components/journal/types/intent.tsx @@ -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({ emitters: ["player"], schema: intentSchema, defaultPayload: () => ({ description: "" }), + render: (p) => ( +
+
+ ๐Ÿ™‹ + Intent +
+

{p.description}

+ {p.context && Object.keys(p.context).length > 0 && ( +
+          {JSON.stringify(p.context, null, 2)}
+        
+ )} +
+ ), }); // --------------------------------------------------------------------------- @@ -37,14 +43,8 @@ registerMessageType({ // --------------------------------------------------------------------------- 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({ label: "Resolve Intent", emitters: ["gm"], schema: resolutionSchema, + render: (p) => ( +
+
+ โœ… + Resolution + re: {p.intentId} +
+

{p.outcome}

+ {p.statChanges && Object.keys(p.statChanges).length > 0 && ( +
+ {Object.entries(p.statChanges).map(([k, v]) => ( + + {k}: {String(v)} + + ))} +
+ )} +
+ ), }); diff --git a/src/components/journal/types/narrative.ts b/src/components/journal/types/narrative.tsx similarity index 51% rename from src/components/journal/types/narrative.ts rename to src/components/journal/types/narrative.tsx index 26b48b5..67f0e01 100644 --- a/src/components/journal/types/narrative.ts +++ b/src/components/journal/types/narrative.tsx @@ -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; @@ -18,9 +19,5 @@ registerMessageType({ 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.text}

, }); diff --git a/src/components/journal/types/roll.ts b/src/components/journal/types/roll.tsx similarity index 57% rename from src/components/journal/types/roll.ts rename to src/components/journal/types/roll.tsx index f02ee3a..0f78924 100644 --- a/src/components/journal/types/roll.ts +++ b/src/components/journal/types/roll.tsx @@ -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({ emitters: ["gm"], schema: rollRequestSchema, defaultPayload: () => ({ notation: "1d20", label: "" }), + render: (p) => ( +
+
+ ๐ŸŽฒ + {p.notation} + {p.label} +
+ {p.description &&

{p.description}

} + {p.target !== undefined && ( +

Target: {p.target}

+ )} +
+ ), }); // --------------------------------------------------------------------------- @@ -38,15 +44,10 @@ registerMessageType({ // --------------------------------------------------------------------------- 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({ 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 ( +
+
+ ๐ŸŽฒ + + {p.notation} โ†’ [{diceStr}]{modStr} ={" "} + {p.total} + +
+
+ ); + }, });