feat: move revert action to message cards and improve article links
- Move the revert functionality from the journal input to individual message cards for better UX. - Implement `RevealLink` component for article reveals to allow navigation without losing URL parameters. - Add helper functions to format article paths and slugs into human-readable titles.
This commit is contained in:
parent
276241f3b0
commit
e216b94e25
|
|
@ -20,12 +20,7 @@ import {
|
||||||
Switch,
|
Switch,
|
||||||
Match,
|
Match,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import {
|
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
||||||
sendMessage,
|
|
||||||
revertLatest,
|
|
||||||
canRevert,
|
|
||||||
useJournalStream,
|
|
||||||
} from "../stores/journalStream";
|
|
||||||
import {
|
import {
|
||||||
useJournalCompletions,
|
useJournalCompletions,
|
||||||
ensureCompletions,
|
ensureCompletions,
|
||||||
|
|
@ -123,10 +118,7 @@ export const JournalInput: Component = () => {
|
||||||
textareaRef?.focus();
|
textareaRef?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRevert() {
|
// ---- Completions ----
|
||||||
revertLatest();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Scroll selected completion into view ----
|
// ---- Scroll selected completion into view ----
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
@ -313,8 +305,6 @@ export const JournalInput: Component = () => {
|
||||||
onCleanup(() => document.removeEventListener("mousedown", handler));
|
onCleanup(() => document.removeEventListener("mousedown", handler));
|
||||||
});
|
});
|
||||||
|
|
||||||
const showRevert = () => canRevert() && stream.myName === "gm";
|
|
||||||
|
|
||||||
// Completions placeholder — shown in the dropdown area when no matches
|
// Completions placeholder — shown in the dropdown area when no matches
|
||||||
function renderCompletionsDropdown() {
|
function renderCompletionsDropdown() {
|
||||||
const comps = currentCompletions();
|
const comps = currentCompletions();
|
||||||
|
|
@ -414,15 +404,6 @@ export const JournalInput: Component = () => {
|
||||||
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
|
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1 ml-auto">
|
<div class="flex items-center gap-1 ml-auto">
|
||||||
<Show when={showRevert}>
|
|
||||||
<button
|
|
||||||
onClick={handleRevert}
|
|
||||||
class="text-xs text-gray-400 hover:text-red-500 px-1.5 py-0.5"
|
|
||||||
title="Revert last message"
|
|
||||||
>
|
|
||||||
↩
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={sending() || !text().trim()}
|
disabled={sending() || !text().trim()}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,16 @@
|
||||||
import { Component, Show, createMemo } from "solid-js";
|
import { Component, Show, createMemo } from "solid-js";
|
||||||
import type { StreamMessage as StreamMessageType } from "./registry";
|
import type { StreamMessage as StreamMessageType } from "./registry";
|
||||||
import { getMessageType } from "./registry";
|
import { getMessageType } from "./registry";
|
||||||
|
import {
|
||||||
|
revertLatest,
|
||||||
|
canRevert,
|
||||||
|
useJournalStream,
|
||||||
|
} from "../stores/journalStream";
|
||||||
|
|
||||||
export const StreamMessageCard: Component<{
|
export const StreamMessageCard: Component<{
|
||||||
message: StreamMessageType;
|
message: StreamMessageType;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
|
const stream = useJournalStream();
|
||||||
const def = createMemo(() => getMessageType(props.message.type));
|
const def = createMemo(() => getMessageType(props.message.type));
|
||||||
const rendered = createMemo(() =>
|
const rendered = createMemo(() =>
|
||||||
def()?.render?.(props.message.payload, props.message as any),
|
def()?.render?.(props.message.payload, props.message as any),
|
||||||
|
|
@ -19,6 +25,11 @@ export const StreamMessageCard: Component<{
|
||||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isMine = () => props.message.sender === stream.myName;
|
||||||
|
const latestSeq = () => stream.senderSeq[stream.myName] ?? 0;
|
||||||
|
const canRevertThis = () =>
|
||||||
|
isMine() && props.message.seq === latestSeq() && !props.message.reverted;
|
||||||
|
|
||||||
const senderClass = () =>
|
const senderClass = () =>
|
||||||
props.message.sender === "gm"
|
props.message.sender === "gm"
|
||||||
? "text-purple-600 font-semibold"
|
? "text-purple-600 font-semibold"
|
||||||
|
|
@ -34,7 +45,19 @@ export const StreamMessageCard: Component<{
|
||||||
<div class="flex items-center gap-1.5 px-2.5 py-1 bg-gray-50 border-b border-gray-100">
|
<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 ${senderClass()}`}>{props.message.sender}</span>
|
||||||
<span class="text-xs text-gray-400">{time()}</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">
|
<Show when={canRevertThis()}>
|
||||||
|
<button
|
||||||
|
onClick={() => revertLatest()}
|
||||||
|
class="text-xs text-gray-300 hover:text-red-500 ml-auto px-0.5 leading-none"
|
||||||
|
title="Revert this message"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<span
|
||||||
|
class="text-xs text-gray-300 bg-gray-100 px-1 rounded"
|
||||||
|
classList={{ "ml-auto": !canRevertThis() }}
|
||||||
|
>
|
||||||
{props.message.type}
|
{props.message.type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
* Built-in message type: article.reveal
|
* Built-in message type: article.reveal
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Component } from "solid-js";
|
||||||
|
import { useNavigate } from "@solidjs/router";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { registerMessageType } from "../registry";
|
import { registerMessageType } from "../registry";
|
||||||
import { journalSetState } from "../../stores/journalStream";
|
import { journalSetState } from "../../stores/journalStream";
|
||||||
|
|
@ -15,25 +17,69 @@ const schema = z.object({
|
||||||
|
|
||||||
export type ArticleRevealPayload = z.infer<typeof schema>;
|
export type ArticleRevealPayload = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
function fileNameFromPath(path: string): string {
|
||||||
|
const parts = path.split("/").filter(Boolean);
|
||||||
|
return parts[parts.length - 1] || path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section slug → human-readable title (e.g. "attack-rules" → "Attack Rules") */
|
||||||
|
function slugToTitle(slug: string): string {
|
||||||
|
return slug
|
||||||
|
.split("-")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clickable link that navigates without dropping URL params */
|
||||||
|
const RevealLink: Component<ArticleRevealPayload> = (p) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const target = () => {
|
||||||
|
let t = p.path;
|
||||||
|
if (p.section) t += `#${p.section}`;
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayLabel = () => {
|
||||||
|
if (p.label) return p.label;
|
||||||
|
const file = fileNameFromPath(p.path);
|
||||||
|
if (p.section) return `${file} § ${slugToTitle(p.section)}`;
|
||||||
|
return file;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(target());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-lg">📄</span>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={target()}
|
||||||
|
onClick={handleClick}
|
||||||
|
class="text-blue-600 underline text-sm"
|
||||||
|
>
|
||||||
|
{displayLabel()}
|
||||||
|
</a>
|
||||||
|
{p.section && (
|
||||||
|
<span class="text-xs text-gray-400 ml-1">
|
||||||
|
§ {slugToTitle(p.section)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
registerMessageType<ArticleRevealPayload>({
|
registerMessageType<ArticleRevealPayload>({
|
||||||
type: "article.reveal",
|
type: "article.reveal",
|
||||||
label: "Reveal Article",
|
label: "Reveal Article",
|
||||||
emitters: ["gm"],
|
emitters: ["gm"],
|
||||||
schema,
|
schema,
|
||||||
defaultPayload: () => ({ path: "/" }),
|
defaultPayload: () => ({ path: "/" }),
|
||||||
render: (p) => (
|
render: (p) => <RevealLink {...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) => {
|
reducer: (p) => {
|
||||||
journalSetState(
|
journalSetState(
|
||||||
produce((s) => {
|
produce((s) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue