mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 02:01:22 +02:00
feat: better generic event detail relay list and JSON viewer
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user