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,
|
||||
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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue