feat: better generic event detail relay list and JSON viewer

This commit is contained in:
Alejandro Gómez
2025-12-14 16:41:43 +01:00
parent e5c871617e
commit 9496af6273
6 changed files with 253 additions and 143 deletions

View File

@@ -109,84 +109,84 @@ export default function CommandLauncher({
<VisuallyHidden>
<DialogTitle>Command Launcher</DialogTitle>
</VisuallyHidden>
<Command
label="Command Launcher"
className="grimoire-command-content"
shouldFilter={false}
>
<div className="command-launcher-wrapper">
<Command.Input
value={input}
onValueChange={setInput}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="command-input"
autoFocus
/>
<Command
label="Command Launcher"
className="grimoire-command-content"
shouldFilter={false}
>
<div className="command-launcher-wrapper">
<Command.Input
value={input}
onValueChange={setInput}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="command-input"
autoFocus
/>
<Command.List className="command-list">
<Command.Empty className="command-empty">
{commandName
? `No command found: ${commandName}`
: "Start typing..."}
</Command.Empty>
<Command.List className="command-list">
<Command.Empty className="command-empty">
{commandName
? `No command found: ${commandName}`
: "Start typing..."}
</Command.Empty>
{categories.map((category) => (
<Command.Group
key={category}
heading={category}
className="command-group"
>
{filteredCommands
.filter(([_, cmd]) => cmd.category === category)
.map(([name, cmd]) => {
const isExactMatch = name === commandName;
return (
<Command.Item
key={name}
value={name}
onSelect={() => handleSelect(name)}
className="command-item"
data-exact-match={isExactMatch}
>
<div className="command-item-content">
<div className="command-item-name">
<span className="command-name">{name}</span>
{cmd.synopsis !== name && (
<span className="command-args">
{cmd.synopsis.replace(name, "").trim()}
</span>
)}
{isExactMatch && (
<span className="command-match-indicator">
</span>
)}
</div>
<div className="command-item-description">
{cmd.description.split(".")[0]}
</div>
{categories.map((category) => (
<Command.Group
key={category}
heading={category}
className="command-group"
>
{filteredCommands
.filter(([_, cmd]) => cmd.category === category)
.map(([name, cmd]) => {
const isExactMatch = name === commandName;
return (
<Command.Item
key={name}
value={name}
onSelect={() => handleSelect(name)}
className="command-item"
data-exact-match={isExactMatch}
>
<div className="command-item-content">
<div className="command-item-name">
<span className="command-name">{name}</span>
{cmd.synopsis !== name && (
<span className="command-args">
{cmd.synopsis.replace(name, "").trim()}
</span>
)}
{isExactMatch && (
<span className="command-match-indicator">
</span>
)}
</div>
</Command.Item>
);
})}
</Command.Group>
))}
</Command.List>
<div className="command-item-description">
{cmd.description.split(".")[0]}
</div>
</div>
</Command.Item>
);
})}
</Command.Group>
))}
</Command.List>
<div className="command-footer">
<div>
<kbd></kbd> navigate
<kbd></kbd> execute
<kbd>esc</kbd> close
</div>
{recognizedCommand && (
<div className="command-footer-status">Ready to execute</div>
)}
<div className="command-footer">
<div>
<kbd></kbd> navigate
<kbd></kbd> execute
<kbd>esc</kbd> close
</div>
{recognizedCommand && (
<div className="command-footer-status">Ready to execute</div>
)}
</div>
</Command>
</DialogContent>
</Dialog>
</div>
</Command>
</DialogContent>
</Dialog>
);
}

View File

@@ -7,23 +7,103 @@ import { Kind3DetailView } from "./nostr/kinds/Kind3Renderer";
import { Kind30023DetailRenderer } from "./nostr/kinds/Kind30023DetailRenderer";
import { Kind9802DetailRenderer } from "./nostr/kinds/Kind9802DetailRenderer";
import { Kind10002DetailRenderer } from "./nostr/kinds/Kind10002DetailRenderer";
import { JsonViewer } from "./JsonViewer";
import { RelayLink } from "./nostr/RelayLink";
import {
Copy,
Check,
ChevronDown,
ChevronRight,
CopyCheck,
FileJson,
Wifi,
Circle,
Loader2,
WifiOff,
XCircle,
ShieldCheck,
ShieldAlert,
ShieldX,
ShieldQuestion,
Shield,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { nip19, kinds } from "nostr-tools";
import { useCopy } from "../hooks/useCopy";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import { useRelayState } from "@/hooks/useRelayState";
import type { RelayState } from "@/types/relay-state";
export interface EventDetailViewerProps {
pointer: EventPointer | AddressPointer;
}
// Helper functions for relay status icons (from ReqViewer)
function getConnectionIcon(relay: RelayState | undefined) {
if (!relay) {
return {
icon: <WifiOff className="size-3 text-muted-foreground" />,
label: "Unknown",
};
}
const iconMap = {
connected: {
icon: <Wifi className="size-3 text-green-500" />,
label: "Connected",
},
connecting: {
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
label: "Connecting",
},
disconnected: {
icon: <WifiOff className="size-3 text-muted-foreground" />,
label: "Disconnected",
},
error: {
icon: <XCircle className="size-3 text-red-500" />,
label: "Connection Error",
},
};
return iconMap[relay.connectionState];
}
function getAuthIcon(relay: RelayState | undefined) {
if (!relay || relay.authStatus === "none") {
return null;
}
const iconMap = {
authenticated: {
icon: <ShieldCheck className="size-3 text-green-500" />,
label: "Authenticated",
},
challenge_received: {
icon: <ShieldQuestion className="size-3 text-yellow-500" />,
label: "Challenge Received",
},
authenticating: {
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
label: "Authenticating",
},
failed: {
icon: <ShieldX className="size-3 text-red-500" />,
label: "Authentication Failed",
},
rejected: {
icon: <ShieldAlert className="size-3 text-muted-foreground" />,
label: "Authentication Rejected",
},
none: {
icon: <Shield className="size-3 text-muted-foreground" />,
label: "No Authentication",
},
};
return iconMap[relay.authStatus] || iconMap.none;
}
/**
* EventDetailViewer - Detailed view for a single event
* Shows compact metadata header and rendered content
@@ -31,9 +111,8 @@ export interface EventDetailViewerProps {
export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
const event = useNostrEvent(pointer);
const [showJson, setShowJson] = useState(false);
const [showRelays, setShowRelays] = useState(false);
const { copy: copyBech32, copied: copiedBech32 } = useCopy();
const { copy: copyJson, copied: copiedJson } = useCopy();
const { relays: relayStates } = useRelayState();
// Loading state
if (!event) {
@@ -72,6 +151,17 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
// minute: "2-digit",
// });
// Get relay state for each relay
const relayStatesForEvent = relays
? relays.map((url) => ({
url,
state: relayStates[url],
}))
: [];
const connectedCount = relayStatesForEvent.filter(
(r) => r.state?.connectionState === "connected",
).length;
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Compact Header - Single Line */}
@@ -81,9 +171,10 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
onClick={() => copyBech32(bech32Id)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors truncate min-w-0"
title={bech32Id}
aria-label="Copy event ID"
>
{copiedBech32 ? (
<Check className="size-3 flex-shrink-0 text-green-500" />
<CopyCheck className="size-3 flex-shrink-0" />
) : (
<Copy className="size-3 flex-shrink-0" />
)}
@@ -94,72 +185,79 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
{/* Right: Relay Count and JSON Toggle */}
<div className="flex items-center gap-3 flex-shrink-0">
{/* Relay Dropdown */}
{relays && relays.length > 0 && (
<button
onClick={() => setShowRelays(!showRelays)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showRelays ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
<Wifi className="size-3" />
<span>{relays.length}</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
aria-label={`Event seen on ${relays.length} relay${relays.length !== 1 ? "s" : ""}`}
>
<Wifi className="size-3" />
<span>
{connectedCount}/{relays.length}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
{relayStatesForEvent.map(({ url, state }) => {
const connIcon = getConnectionIcon(state);
const authIcon = getAuthIcon(state);
return (
<DropdownMenuItem
key={url}
className="flex items-center justify-between gap-2"
>
<RelayLink
url={url}
showInboxOutbox={false}
className="flex-1 min-w-0 hover:bg-transparent"
iconClassname="size-3"
urlClassname="text-xs"
/>
<div
className="flex items-center gap-1.5 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
{authIcon && (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{authIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{authIcon.label}</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{connIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{connIcon.label}</p>
</TooltipContent>
</Tooltip>
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* JSON Toggle */}
<button
onClick={() => setShowJson(!showJson)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
aria-label="View raw JSON"
>
{showJson ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
<FileJson className="size-3" />
</button>
</div>
</div>
{/* Expandable Relays */}
{showRelays && relays && relays.length > 0 && (
<div className="border-b border-border px-4 py-2 bg-muted">
<div className="flex flex-col gap-2">
{relays.map((relay) => (
<div key={relay} className="flex items-center gap-2">
<Circle className="size-2 fill-green-500 text-green-500" />
<span className="text-xs font-mono text-muted-foreground">
{relay}
</span>
</div>
))}
</div>
</div>
)}
{/* Expandable JSON */}
{showJson && (
<div className="border-b border-border px-4 py-2 bg-muted">
<div className="flex justify-end mb-2">
<button
onClick={() => copyJson(JSON.stringify(event, null, 2))}
className="hover:text-foreground text-muted-foreground transition-colors text-xs flex items-center gap-1"
>
{copiedJson ? (
<Check className="size-3 text-green-500" />
) : (
<Copy className="size-3" />
)}
{copiedJson ? "Copied!" : "Copy JSON"}
</button>
</div>
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-words bg-background p-2 rounded border border-border font-mono">
{JSON.stringify(event, null, 2)}
</pre>
</div>
)}
{/* Rendered Content - Focus Here */}
<div className="flex-1 overflow-y-auto">
{event.kind === kinds.Metadata ? (
@@ -176,6 +274,14 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
<KindRenderer event={event} />
)}
</div>
{/* JSON Viewer Dialog */}
<JsonViewer
data={event}
open={showJson}
onOpenChange={setShowJson}
title="Event JSON"
/>
</div>
);
}

View File

@@ -30,7 +30,9 @@ export function TabBar() {
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{ws.label && ws.label.trim() ? `${ws.number} ${ws.label}` : ws.number}
{ws.label && ws.label.trim()
? `${ws.number} ${ws.label}`
: ws.number}
</button>
))}
<Button

View File

@@ -83,7 +83,11 @@ export default function UserMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="link" aria-label={account ? "User menu" : "Log in"}>
<Button
size="sm"
variant="link"
aria-label={account ? "User menu" : "Log in"}
>
{account ? (
<UserAvatar pubkey={account.pubkey} />
) : (

View File

@@ -4,11 +4,7 @@ import { atomWithStorage, createJSONStorage } from "jotai/utils";
import { GrimoireState, AppId, WindowInstance } from "@/types/app";
import { useLocale } from "@/hooks/useLocale";
import * as Logic from "./logic";
import {
CURRENT_VERSION,
validateState,
migrateState,
} from "@/lib/migrations";
import { CURRENT_VERSION, validateState, migrateState } from "@/lib/migrations";
import { toast } from "sonner";
// Initial State Definition - Empty canvas on first load

View File

@@ -140,7 +140,9 @@ export function migrateState(state: any): GrimoireState {
for (let version = startVersion; version < CURRENT_VERSION; version++) {
const migration = migrations[version];
if (migration) {
console.log(`[Migrations] Applying migration v${version} -> v${version + 1}`);
console.log(
`[Migrations] Applying migration v${version} -> v${version + 1}`,
);
try {
currentState = migration(currentState);
} catch (error) {