style(web): topic chat panel matches mesh-panel idiom
Audit against peer-graph-panel, live-stream-panel, state-timeline-panel, and resource-panel showed the chat used generic shadcn Card chrome instead of the established panel pattern. Refactor swaps the wrapper to the canonical idiom: - rounded-[var(--cm-radius-lg)] + border-[var(--cm-border)] + bg-[var(--cm-bg)] - mono header strip with clay-pulse fetch dot, 11px label, 10px metadata - mono 9px footer status bar (mesh slug · poll cadence · key expiry) - Anthropic Mono via var(--cm-font-mono) on chrome, sans on message body - compose textarea uses cm-bg-elevated + cm-border-hover focus state - error line in cm-fig (#c46686) instead of generic destructive No behavior change — only chrome. Polling, send path, decode logic unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,9 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@turbostarter/ui-web/card";
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
@@ -71,6 +69,16 @@ function fmtTime(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function fmtRelative(iso: string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (ms < 60_000) return `${Math.floor(ms / 1000)}s ago`;
|
||||
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`;
|
||||
if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
const monoStyle = { fontFamily: "var(--cm-font-mono)" } as const;
|
||||
|
||||
export function TopicChatPanel({
|
||||
topicName,
|
||||
meshSlug,
|
||||
@@ -81,6 +89,8 @@ export function TopicChatPanel({
|
||||
const [draft, setDraft] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [lastPollAt, setLastPollAt] = useState<number | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const headers = useMemo(
|
||||
@@ -92,6 +102,7 @@ export function TopicChatPanel({
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setIsFetching(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/topics/${encodeURIComponent(topicName)}/messages?limit=100`,
|
||||
@@ -104,8 +115,11 @@ export function TopicChatPanel({
|
||||
const json = (await res.json()) as { messages: TopicMessage[] };
|
||||
setMessages(json.messages.slice().reverse());
|
||||
setError(null);
|
||||
setLastPollAt(Date.now());
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [headers, topicName]);
|
||||
|
||||
@@ -148,56 +162,83 @@ export function TopicChatPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const secondsSincePoll = lastPollAt
|
||||
? Math.max(0, Math.floor((Date.now() - lastPollAt) / 1000))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card className="flex h-[70vh] flex-col">
|
||||
<CardHeader className="flex-row items-center justify-between border-b py-3">
|
||||
<CardTitle className="text-base font-medium">
|
||||
<span className="text-muted-foreground">#</span>
|
||||
{topicName}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{meshSlug}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
key expires {fmtTime(apiKeyExpiresAt)}
|
||||
<div className="flex h-[70vh] flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
|
||||
{/* Header — mono strip, clay-pulse dot, metadata right */}
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
|
||||
style={monoStyle}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={
|
||||
"inline-block h-2 w-2 rounded-full " +
|
||||
(isFetching
|
||||
? "bg-[var(--cm-clay)] animate-pulse"
|
||||
: "bg-emerald-500")
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
|
||||
#{topicName}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
|
||||
{messages.length} msg ·{" "}
|
||||
{isFetching
|
||||
? "polling…"
|
||||
: `${secondsSincePoll ?? "—"}s ago`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CardContent
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-4"
|
||||
>
|
||||
{/* Message stream */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-muted-foreground py-8 text-center text-sm">
|
||||
No messages yet. Be the first.
|
||||
<p
|
||||
className="py-12 text-center text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||
style={monoStyle}
|
||||
>
|
||||
no envelopes on this topic yet
|
||||
</p>
|
||||
) : (
|
||||
<ol className="flex flex-col gap-3">
|
||||
<ol className="flex flex-col gap-4">
|
||||
{messages.map((m) => (
|
||||
<li key={m.id} className="flex flex-col gap-0.5">
|
||||
<div className="text-muted-foreground flex items-baseline gap-2 text-xs">
|
||||
<span className="font-medium text-foreground">
|
||||
<li key={m.id} className="flex flex-col gap-1">
|
||||
<div
|
||||
className="flex items-baseline gap-2 text-[10px]"
|
||||
style={monoStyle}
|
||||
>
|
||||
<span className="text-[var(--cm-fg)] font-medium">
|
||||
{m.senderName || m.senderPubkey.slice(0, 8)}
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{m.senderPubkey.slice(0, 6)}…
|
||||
<span className="text-[var(--cm-fg-tertiary)]">
|
||||
{m.senderPubkey.slice(0, 8)}
|
||||
</span>
|
||||
<span className="text-[var(--cm-fg-tertiary)]">
|
||||
{fmtTime(m.createdAt)}
|
||||
</span>
|
||||
<span>{fmtTime(m.createdAt)}</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">
|
||||
<p className="text-[var(--cm-fg)] text-sm leading-relaxed whitespace-pre-wrap break-words">
|
||||
{decodeIncoming(m.ciphertext)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
<div className="border-t p-3">
|
||||
{/* Compose */}
|
||||
<div className="border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 p-3">
|
||||
{error ? (
|
||||
<p className="mb-2 text-xs text-destructive">{error}</p>
|
||||
<p
|
||||
className="mb-2 text-[10px] text-[#c46686]"
|
||||
style={monoStyle}
|
||||
>
|
||||
error · {error}
|
||||
</p>
|
||||
) : null}
|
||||
<form
|
||||
className="flex gap-2"
|
||||
@@ -209,9 +250,9 @@ export function TopicChatPanel({
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={`Message #${topicName}…`}
|
||||
placeholder={`message #${topicName}…`}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-md border bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="flex-1 resize-none rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3 py-2 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)] focus:border-[var(--cm-border-hover)] focus:outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -220,10 +261,26 @@ export function TopicChatPanel({
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" disabled={sending || !draft.trim()}>
|
||||
{sending ? "…" : "Send"}
|
||||
{sending ? "…" : "send"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Status footer — 9px mono, matches peer-graph + state-timeline footers */}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-5 gap-y-1 border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
|
||||
style={monoStyle}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--cm-clay)]" />
|
||||
mesh · {meshSlug}
|
||||
</span>
|
||||
<span>polling every {POLL_INTERVAL_MS / 1000}s</span>
|
||||
<span>key valid until {fmtTime(apiKeyExpiresAt)}</span>
|
||||
<span className="ml-auto">
|
||||
v0.2.0 · plaintext base64 · per-topic crypto in v0.3.0
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user