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:
hypercross 2026-07-06 17:26:37 +08:00
parent 276241f3b0
commit e216b94e25
3 changed files with 85 additions and 35 deletions

View File

@ -20,12 +20,7 @@ import {
Switch,
Match,
} from "solid-js";
import {
sendMessage,
revertLatest,
canRevert,
useJournalStream,
} from "../stores/journalStream";
import { sendMessage, useJournalStream } from "../stores/journalStream";
import {
useJournalCompletions,
ensureCompletions,
@ -123,10 +118,7 @@ export const JournalInput: Component = () => {
textareaRef?.focus();
}
function handleRevert() {
revertLatest();
}
// ---- Completions ----
// ---- Scroll selected completion into view ----
createEffect(() => {
@ -313,8 +305,6 @@ export const JournalInput: Component = () => {
onCleanup(() => document.removeEventListener("mousedown", handler));
});
const showRevert = () => canRevert() && stream.myName === "gm";
// Completions placeholder — shown in the dropdown area when no matches
function renderCompletionsDropdown() {
const comps = currentCompletions();
@ -414,15 +404,6 @@ export const JournalInput: Component = () => {
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
</Show>
<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
onClick={handleSend}
disabled={sending() || !text().trim()}

View File

@ -5,10 +5,16 @@
import { Component, Show, createMemo } from "solid-js";
import type { StreamMessage as StreamMessageType } from "./registry";
import { getMessageType } from "./registry";
import {
revertLatest,
canRevert,
useJournalStream,
} from "../stores/journalStream";
export const StreamMessageCard: Component<{
message: StreamMessageType;
}> = (props) => {
const stream = useJournalStream();
const def = createMemo(() => getMessageType(props.message.type));
const rendered = createMemo(() =>
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" });
});
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 = () =>
props.message.sender === "gm"
? "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">
<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">
<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}
</span>
</div>

View File

@ -2,6 +2,8 @@
* Built-in message type: article.reveal
*/
import { Component } from "solid-js";
import { useNavigate } from "@solidjs/router";
import { z } from "zod";
import { registerMessageType } from "../registry";
import { journalSetState } from "../../stores/journalStream";
@ -15,25 +17,69 @@ const schema = z.object({
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>({
type: "article.reveal",
label: "Reveal Article",
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>
),
render: (p) => <RevealLink {...p} />,
reducer: (p) => {
journalSetState(
produce((s) => {