diff --git a/src/components/Walkthrough.tsx b/src/components/Walkthrough.tsx new file mode 100644 index 0000000..9cd5ca1 --- /dev/null +++ b/src/components/Walkthrough.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from 'react'; + +export const WALK_STORAGE_KEY = 'nem-walk-seen'; + +interface WalkthroughProps { + onClose: () => void; +} + +const steps = [ + { + label: 'Step 01 / 03', + title: 'Point it at a relay', + render: () => ( + <> + Nostr is a network of relays — public servers that hold events. Add at least one{' '} + wss:// URL to query. You can add several and this tool will query them in parallel. + + ), + }, + { + label: 'Step 02 / 03', + title: 'Filter by Kind or NIP', + render: () => ( + <> + Each event has a numeric kind. If you know it, use Kind. If not, + search by NIP — the spec number — and we'll resolve its kinds automatically. + Then narrow with author, tags, or time range. + + ), + }, + { + label: 'Step 03 / 03', + title: 'Search vs Stream', + render: () => ( + <> + Search fetches a snapshot (past events, bounded by limit).{' '} + Stream keeps a live socket open and prints new events as they land. + Use Stream to debug relays or watch a kind in real time. + + ), + }, +]; + +export function Walkthrough({ onClose }: WalkthroughProps) { + const [step, setStep] = useState(0); + const s = steps[step]; + const isLast = step === steps.length - 1; + const primaryBtnRef = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + primaryBtnRef.current?.focus(); + + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('keydown', onKey); + previouslyFocused?.focus?.(); + }; + }, [onClose]); + + return ( +
+
e.stopPropagation()}> +
{s.label}
+

{s.title}

+

{s.render()}

+
+
+ {steps.map((_, i) => ( + + ))} +
+
+ + +
+
+
+
+ ); +} diff --git a/src/data/presets.ts b/src/data/presets.ts new file mode 100644 index 0000000..ef55a7c --- /dev/null +++ b/src/data/presets.ts @@ -0,0 +1,21 @@ +export const SUGGESTED_RELAYS: string[] = [ + 'relay.damus.io', + 'relay.primal.net', + 'nos.lol', + 'relay.nostr.band', + 'relay.mostro.network', +]; + +export interface QueryPreset { + id: string; + title: string; + desc: string; + kind: string; +} + +export const PRESETS: QueryPreset[] = [ + { id: 'notes', title: 'Recent notes', desc: 'kind:1 · short text notes', kind: '1' }, + { id: 'mostro', title: 'Mostro P2P orders', desc: 'kind:38383 · NIP-69', kind: '38383' }, + { id: 'zaps', title: 'Zap receipts', desc: 'kind:9735 · NIP-57', kind: '9735' }, + { id: 'profiles', title: 'User metadata', desc: 'kind:0 · profile updates', kind: '0' }, +]; diff --git a/src/index.css b/src/index.css index e212369..82cded1 100644 --- a/src/index.css +++ b/src/index.css @@ -4,44 +4,76 @@ @layer base { :root { - --background: 240 9% 12%; /* #1e1e2e */ - --foreground: 0 0% 88%; /* #e0e0e0 */ + /* Deep dark base with subtle violet glow (applied on body via radial-gradient) */ + --bg-main: 240 26% 8%; /* #0F0F1A */ + --bg-soft: 240 16% 15%; /* #1F1F2B */ + --surface-card: 240 16% 15%; /* #1F1F2B */ + --surface-input: 240 15% 20%; /* #2A2A3A */ + --surface-border: 240 14% 25%; /* #3A3A4A */ + --text-hi: 267 32% 96%; /* #F3F1F7 */ + --text-lo: 240 16% 76%; /* #B8B8CC */ + --text-mute: 240 10% 55%; /* #858598 */ + --accent-soft: 258 90% 66%; /* #8B5CF6 */ + --accent-bright: 258 90% 76%; /* #A78BFA */ + --accent-glow: 251 96% 85%; /* #C4B5FD */ + --accent-2: 239 84% 67%; /* #6366F1 */ - --card: 240 9% 18%; /* #2e2e3e */ - --card-foreground: 0 0% 88%; /* #e0e0e0 */ + /* shadcn tokens */ + --background: var(--bg-main); + --foreground: var(--text-hi); - --popover: 240 9% 18%; /* #2e2e3e */ - --popover-foreground: 0 0% 88%; /* #e0e0e0 */ + --card: var(--surface-card); + --card-foreground: var(--text-hi); - --primary: 262 83% 78%; /* #a78bfa */ - --primary-foreground: 240 9% 12%; /* #1e1e2e */ + --popover: var(--surface-card); + --popover-foreground: var(--text-hi); - --secondary: 240 9% 18%; /* #2e2e3e */ - --secondary-foreground: 0 0% 88%; /* #e0e0e0 */ + --primary: var(--accent-soft); + --primary-foreground: 0 0% 100%; - --muted: 240 9% 18%; /* #2e2e3e */ - --muted-foreground: 264 22% 72%; /* #c3b9d6 */ + --secondary: var(--surface-input); + --secondary-foreground: var(--text-hi); - --accent: 262 83% 78%; /* #a78bfa */ - --accent-foreground: 240 9% 12%; /* #1e1e2e */ + --muted: var(--bg-soft); + --muted-foreground: var(--text-lo); - --destructive: 0 63% 31%; - --destructive-foreground: 0 0% 88%; /* #e0e0e0 */ + --accent: var(--accent-soft); + --accent-foreground: 0 0% 100%; - --border: 253 17% 32%; /* #4b445c */ - --input: 240 9% 18%; /* #2e2e3e */ - --ring: 262 83% 78%; /* #a78bfa */ + --destructive: 0 63% 60%; + --destructive-foreground: var(--text-hi); + + --border: var(--surface-border); + --input: var(--surface-input); + --ring: var(--accent-soft); --radius: 0.5rem; - --sidebar-background: 240 9% 12%; /* #1e1e2e */ - --sidebar-foreground: 0 0% 88%; /* #e0e0e0 */ - --sidebar-primary: 262 83% 78%; /* #a78bfa */ - --sidebar-primary-foreground: 240 9% 12%; /* #1e1e2e */ - --sidebar-accent: 240 9% 18%; /* #2e2e3e */ - --sidebar-accent-foreground: 0 0% 88%; /* #e0e0e0 */ - --sidebar-border: 253 17% 32%; /* #4b445c */ - --sidebar-ring: 262 83% 78%; /* #a78bfa */ + --sidebar-background: var(--bg-main); + --sidebar-foreground: var(--text-hi); + --sidebar-primary: var(--accent-soft); + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: var(--surface-card); + --sidebar-accent-foreground: var(--text-hi); + --sidebar-border: var(--surface-border); + --sidebar-ring: var(--accent-soft); + + /* Raw hex helpers (for non-HSL contexts: gradients, shadows, code blocks) */ + --c-bg: #0F0F1A; + --c-bg-top: #1E1B3A; + --c-bg-bottom: #0A0A12; + --c-bg-soft: #1F1F2B; + --c-card: #1F1F2B; + --c-input: #2A2A3A; + --c-border: #3A3A4A; + --c-border-soft: #2F2F40; + --c-text: #F3F1F7; + --c-text-dim: #B8B8CC; + --c-text-mute: #858598; + --c-accent: #8B5CF6; + --c-accent-2: #6366F1; + --c-accent-bright: #A78BFA; + --c-accent-glow: #C4B5FD; } } @@ -53,12 +85,477 @@ body { @apply bg-background text-foreground; - background: #1e1e2e; - background-image: none; + background: radial-gradient(circle at top, var(--c-bg-top), var(--c-bg) 60%, var(--c-bg-bottom)); + background-attachment: fixed; + background-image: radial-gradient(circle at top, var(--c-bg-top), var(--c-bg) 60%, var(--c-bg-bottom)); } } -/* JSON Syntax Highlighting - Purple Nostr Style */ +/* ============ TOPBAR ============ */ +.topbar { + border-bottom: 1px solid var(--c-border-soft); + background: var(--c-bg); + box-shadow: 0 1px 0 rgba(139, 92, 246, 0.05); + position: sticky; + top: 0; + z-index: 40; +} +.topbar-inner { + max-width: 1280px; + margin: 0 auto; + padding: 12px 24px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 16px; + flex-wrap: wrap; +} +.brand { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + letter-spacing: 0.02em; +} +.brand-glyph { + width: 26px; + height: 26px; + border: 1px solid var(--c-accent); + display: grid; + place-items: center; + background: linear-gradient(135deg, rgba(176, 154, 217, 0.18), transparent); + position: relative; + color: var(--c-accent-glow); + font-weight: 700; + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} +.brand-glyph::before { + content: ''; + position: absolute; + inset: 3px; + border: 1px solid rgba(176, 154, 217, 0.45); + pointer-events: none; +} +.brand-name { + font-weight: 600; + color: var(--c-text); + line-height: 1.2; +} +.brand-tag { + color: var(--c-text-mute); + font-size: 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} +.topbar-right { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + font-size: 11px; + color: var(--c-text-dim); +} +.status-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + border: 1px solid var(--c-border); + background: var(--c-card); + border-radius: 2px; + color: var(--c-text-dim); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; +} +.status-pill.clickable { + cursor: pointer; + transition: border-color 0.15s, color 0.15s; + appearance: none; + margin: 0; +} +.status-pill.clickable:hover { + border-color: var(--c-accent); + color: var(--c-accent-glow); +} + +/* ============ LED ============ */ +.led { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--c-text-mute); + box-shadow: 0 0 0 0 currentColor; + flex-shrink: 0; +} +.led.on { + background: #86efac; + animation: nem-pulse 1.8s ease-in-out infinite; +} +.led.warn { background: #fde68a; } +.led.off { background: var(--c-text-mute); } +.led.violet { + background: var(--c-accent); + animation: nem-pulse 1.8s ease-in-out infinite; +} +@keyframes nem-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + +/* ============ PANEL DECORATIONS (subtle corner brackets) ============ */ +.panel-corner { + position: relative; +} +.panel-corner::before, +.panel-corner::after { + content: ''; + position: absolute; + width: 10px; + height: 10px; + pointer-events: none; + opacity: 0.7; +} +.panel-corner::before { + top: -1px; left: -1px; + border-top: 1px solid var(--c-accent); + border-left: 1px solid var(--c-accent); +} +.panel-corner::after { + bottom: -1px; right: -1px; + border-bottom: 1px solid var(--c-accent); + border-right: 1px solid var(--c-accent); +} + +/* ============ SEGMENTED CONTROLS ============ */ +.seg { + display: inline-flex; + border: 1px solid var(--c-border-soft); + background: #1A1A26; + padding: 4px; + gap: 4px; + border-radius: 10px; +} +.seg button { + appearance: none; + background: transparent; + border: 1px solid transparent; + color: var(--c-text-mute); + font-size: 13px; + font-weight: 500; + padding: 7px 14px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 9px; + letter-spacing: -0.01em; + transition: background 0.15s, color 0.15s, border-color 0.15s; + border-radius: 7px; +} +.seg button:hover { color: var(--c-text-dim); } +.seg button.on { + background: var(--c-input); + color: var(--c-text); + font-weight: 600; + border-color: rgba(139, 92, 246, 0.65); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04) inset, 0 2px 8px rgba(139, 92, 246, 0.18); +} +.seg-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--c-border); + flex-shrink: 0; + transition: background 0.15s, box-shadow 0.15s; +} +.seg button.on .seg-dot { + background: var(--c-accent-glow); + box-shadow: 0 0 6px rgba(196, 181, 253, 0.55); +} + +/* ============ STREAM BAR ============ */ +.stream-bar { + display: flex; + align-items: center; + gap: 14px; + padding: 9px 14px; + border: 1px solid var(--c-accent); + background: linear-gradient(90deg, rgba(176, 154, 217, 0.16), transparent); + margin-bottom: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + position: relative; + overflow: hidden; + border-radius: 4px; + flex-wrap: wrap; +} +.stream-bar::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: linear-gradient(180deg, var(--c-accent), var(--c-accent-2)); + animation: nem-pulse-bar 1.6s ease-in-out infinite; +} +@keyframes nem-pulse-bar { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} +.stream-bar .live-label { + color: var(--c-accent); + font-weight: 700; + letter-spacing: 0.15em; + display: inline-flex; + align-items: center; + gap: 8px; +} +.stream-rate { color: var(--c-text-dim); } +.stream-rate .n { color: var(--c-accent-glow); font-weight: 600; } +.stream-phase { + color: var(--c-text-mute); + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 10px; +} + +/* ============ EMPTY STATE ============ */ +.empty-state { + border: 1px dashed var(--c-border); + background: var(--c-card); + padding: 40px 32px; + text-align: center; + position: relative; + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.02); +} +.empty-glyph { + width: 56px; height: 56px; + margin: 0 auto 16px auto; + border: 1px solid var(--c-accent); + display: grid; + place-items: center; + position: relative; + background: radial-gradient(circle at center, rgba(139, 92, 246, 0.2), transparent 70%); +} +.empty-glyph::before, +.empty-glyph::after { + content: ''; + position: absolute; + background: var(--c-accent); +} +.empty-glyph::before { width: 1px; height: 22px; } +.empty-glyph::after { width: 22px; height: 1px; } +.empty-title { + font-size: 17px; + font-weight: 600; + margin: 0 0 8px 0; + letter-spacing: -0.01em; + color: var(--c-text); +} +.empty-desc { + color: var(--c-text-dim); + font-size: 13px; + max-width: 440px; + margin: 0 auto 24px auto; + line-height: 1.6; +} +.empty-desc code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: var(--c-accent-glow); + background: var(--c-bg); + padding: 1px 5px; + border-radius: 3px; +} +.quickstart { + max-width: 640px; + margin: 0 auto; + text-align: left; + border-top: 1px solid var(--c-border); + padding-top: 22px; +} +.quickstart-label { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 10px; + letter-spacing: 0.15em; + color: var(--c-text-mute); + text-transform: uppercase; + margin-bottom: 10px; + text-align: center; +} +.relay-suggestions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + margin-bottom: 20px; +} +.relay-suggest { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + padding: 6px 11px; + background: var(--c-bg); + border: 1px solid var(--c-border); + color: var(--c-text-dim); + cursor: pointer; + transition: all 0.15s; + display: inline-flex; + align-items: center; + gap: 7px; + border-radius: 3px; +} +.relay-suggest:hover { + border-color: var(--c-accent); + color: var(--c-accent-glow); + background: rgba(176, 154, 217, 0.08); +} +.relay-suggest .plus { color: var(--c-accent); font-weight: 600; } +.presets-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} +.preset-card { + padding: 13px 15px; + background: var(--c-bg); + border: 1px solid var(--c-border); + cursor: pointer; + text-align: left; + display: flex; + flex-direction: column; + gap: 4px; + transition: all 0.15s; + position: relative; + color: var(--c-text); + border-radius: 4px; +} +.preset-card:hover { + border-color: var(--c-accent); + background: rgba(176, 154, 217, 0.06); +} +.preset-card:hover .preset-arrow { + transform: translateX(3px); + color: var(--c-accent); +} +.preset-title { + font-size: 13px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: space-between; +} +.preset-arrow { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: var(--c-text-mute); + transition: all 0.2s; + font-size: 14px; +} +.preset-desc { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 10px; + color: var(--c-text-mute); +} +@media (max-width: 600px) { + .presets-grid { grid-template-columns: 1fr; } +} + +/* ============ WALKTHROUGH ============ */ +.walk-overlay { + position: fixed; inset: 0; + background: rgba(27, 24, 38, 0.92); + z-index: 100; + display: grid; + place-items: center; + animation: nem-fade-in 0.25s ease-out; + padding: 16px; +} +@keyframes nem-fade-in { from { opacity: 0; } to { opacity: 1; } } +.walk-card { + width: min(540px, 100%); + border: 1px solid var(--c-border); + background: var(--c-card); + padding: 28px; + position: relative; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55), 0 1px 0 rgba(255, 255, 255, 0.04) inset; + border-radius: 8px; +} +.walk-card::before, +.walk-card::after { + content: ''; position: absolute; + width: 14px; height: 14px; +} +.walk-card::before { + top: -1px; left: -1px; + border-top: 2px solid var(--c-accent); + border-left: 2px solid var(--c-accent); +} +.walk-card::after { + bottom: -1px; right: -1px; + border-bottom: 2px solid var(--c-accent); + border-right: 2px solid var(--c-accent); +} +.walk-step { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 10px; + letter-spacing: 0.18em; + color: var(--c-accent); + text-transform: uppercase; + margin-bottom: 8px; +} +.walk-title { + font-size: 22px; + font-weight: 600; + margin: 0 0 10px 0; + letter-spacing: -0.02em; + color: var(--c-text); +} +.walk-desc { + color: var(--c-text-dim); + font-size: 13.5px; + line-height: 1.6; + margin-bottom: 22px; +} +.walk-desc code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + color: var(--c-accent-glow); + background: var(--c-bg); + padding: 1px 6px; + border: 1px solid var(--c-border); + border-radius: 3px; +} +.walk-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.walk-dots { display: flex; gap: 6px; } +.walk-dot { + width: 20px; height: 2px; + background: var(--c-border); +} +.walk-dot.on { background: var(--c-accent); } + +/* ============ EVENTS ============ */ +.kind-info-bar { + padding: 9px 13px; + background: rgba(176, 154, 217, 0.05); + border: 1px solid var(--c-border); + border-left: 2px solid var(--c-accent); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + color: var(--c-text-dim); + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + border-radius: 3px; +} +.kind-info-bar strong { color: var(--c-accent-glow); font-weight: 500; } +.kind-info-bar a { color: var(--c-accent); text-decoration: none; border-bottom: 1px dashed var(--c-accent); } +.kind-info-bar a:hover { color: var(--c-accent-glow); } + +/* ============ JSON VIEWER ============ */ .json-viewer { background: transparent !important; margin: 0 !important; @@ -68,166 +565,113 @@ font-size: 12px; color: #e0e0e0; } - -.json-viewer .token.property { - color: #d8b4fe; /* Keys */ -} - -.json-viewer .token.string { - color: #a5b4fc; /* String values */ -} - -.json-viewer .token.number { - color: #f9a8d4; /* Numbers */ -} - -.json-viewer .token.boolean { - color: #f9a8d4; /* Booleans */ -} - -.json-viewer .token.null { - color: #c3b9d6; -} - -.json-viewer .token.punctuation { - color: #e0e0e0; -} - -/* Dark theme styles */ -.json-viewer { - color: #e0e0e0; -} - -.json-viewer .token.property { - color: #d8b4fe; /* Keys */ -} - -.json-viewer .token.string { - color: #a5b4fc; /* String values */ -} - -.json-viewer .token.number { - color: #f9a8d4; /* Numbers */ -} - -.json-viewer .token.boolean { - color: #f9a8d4; /* Booleans */ -} - -.json-viewer .token.null { - color: #c3b9d6; -} - -.json-viewer .token.punctuation { - color: #e0e0e0; -} +.json-viewer .token.property { color: #d8b4fe; } +.json-viewer .token.string { color: #a5b4fc; } +.json-viewer .token.number { color: #f9a8d4; } +.json-viewer .token.boolean { color: #f9a8d4; } +.json-viewer .token.null { color: #c3b9d6; } +.json-viewer .token.punctuation { color: #e0e0e0; } /* JSON container styling */ .json-container { - background: hsl(var(--muted) / 0.3); - border: 1px solid hsl(var(--border)); + background: #2e2e3e; + border: 1px solid #4b445c; border-radius: 6px; - padding: 16px; + padding: 14px 16px; margin: 8px 0; overflow: auto; max-height: 500px; - backdrop-filter: blur(8px); - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); -} - -.json-container { - background: #2e2e3e; - border: 1px solid #4b445c; box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.2); } -/* Custom scrollbar for JSON containers */ .json-container::-webkit-scrollbar { width: 8px; height: 8px; } - .json-container::-webkit-scrollbar-track { background: #2e2e3e; } - .json-container::-webkit-scrollbar-thumb { background: #4b445c; border-radius: 4px; } - .json-container::-webkit-scrollbar-thumb:hover { background: #a78bfa; } -/* Button styling */ +/* ============ BUTTON / INPUT OVERRIDES (backwards compat with existing Tailwind-ish classes) ============ */ .bg-accent\/80 { - background-color: #a78bfa !important; - color: #1e1e2e !important; - transition: all 0.2s ease; + background: linear-gradient(135deg, var(--c-accent), var(--c-accent-2)) !important; + background-color: var(--c-accent) !important; + color: #fff !important; + border-color: transparent !important; + box-shadow: 0 4px 20px rgba(139, 92, 246, 0.25); + transition: box-shadow 0.15s ease, transform 0.15s ease, filter 0.15s ease; } - .bg-accent\/80:hover { - background-color: #c084fc !important; - box-shadow: 0 0 8px rgba(167, 139, 250, 0.4); + filter: brightness(1.08); + box-shadow: 0 6px 24px rgba(139, 92, 246, 0.35); +} +.bg-accent\/80:active { + transform: translateY(1px); + box-shadow: 0 2px 10px rgba(139, 92, 246, 0.25); } .bg-accent\/10 { - background-color: #2e2e3e !important; - color: #e0e0e0 !important; - border-color: #4b445c !important; - transition: all 0.2s ease; + background-color: var(--c-input) !important; + color: var(--c-text) !important; + border-color: var(--c-border) !important; + transition: all 0.15s ease; } - .bg-accent\/10:hover { - background-color: #3e3e4e !important; - box-shadow: 0 0 8px rgba(167, 139, 250, 0.3); + background-color: var(--c-card) !important; + border-color: var(--c-accent) !important; } .bg-accent\/20 { - background-color: #2e2e3e !important; - color: #e0e0e0 !important; - border-color: #4b445c !important; - transition: all 0.2s ease; + background-color: var(--c-input) !important; + color: var(--c-text) !important; + border-color: var(--c-border) !important; + transition: all 0.15s ease; } - .bg-accent\/20:hover { - background-color: #3e3e4e !important; - box-shadow: 0 0 8px rgba(167, 139, 250, 0.3); + background-color: var(--c-card) !important; + border-color: var(--c-accent) !important; } .border-accent\/30 { - border-color: #4b445c !important; + border-color: var(--c-border) !important; } .border-accent\/50 { - border-color: rgba(255, 255, 255, 0.5) !important; + border-color: var(--c-accent) !important; } .focus\:border-accent\/50:focus { - border-color: #a78bfa !important; - box-shadow: 0 0 0 1px rgba(167, 139, 250, 0.5); + border-color: var(--c-accent) !important; + box-shadow: 0 0 0 1px rgba(176, 154, 217, 0.45); } /* Card styling */ .bg-card\/50 { - background-color: #2e2e3e !important; + background-color: var(--c-card) !important; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35), 0 1px 0 rgba(255, 255, 255, 0.03) inset; } .border-accent\/20 { - border-color: #4b445c !important; + border-color: var(--c-border-soft) !important; } .hover\:border-accent\/40:hover { - border-color: #a78bfa !important; + border-color: var(--c-accent) !important; } .bg-background\/50 { - background-color: #2e2e3e !important; + background-color: var(--c-input) !important; } /* Input field highlighting when filled */ .bg-accent\/5 { - background-color: rgba(255, 255, 255, 0.08) !important; - border-color: rgba(255, 255, 255, 0.3) !important; -} \ No newline at end of file + background-color: rgba(139, 92, 246, 0.08) !important; + border-color: rgba(139, 92, 246, 0.4) !important; +} diff --git a/src/pages/EventMonitor.tsx b/src/pages/EventMonitor.tsx index aa1eb80..c95610a 100644 --- a/src/pages/EventMonitor.tsx +++ b/src/pages/EventMonitor.tsx @@ -5,15 +5,15 @@ import { nip19 } from 'nostr-tools'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; import { ClickTooltip } from '@/components/ClickTooltip'; import { JsonViewer } from '@/components/JsonViewer'; -import { Copy, Check, Plus, X } from 'lucide-react'; +import { Walkthrough, WALK_STORAGE_KEY } from '@/components/Walkthrough'; +import { Copy, Check, Plus, X, ChevronDown, ChevronUp } from 'lucide-react'; import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; import { getKindInfo, getKindsForNip, getNipInfo } from '@/data/kindInfo'; -import { ChevronDown, ChevronUp } from 'lucide-react'; +import { SUGGESTED_RELAYS, PRESETS, QueryPreset } from '@/data/presets'; interface EventFilters { relays: string[]; @@ -29,9 +29,10 @@ interface EventWithRelay extends NostrEvent { relayUrls: string[]; } +type QueryType = 'kind' | 'nip'; + const MAX_STREAM_EVENTS = 500; -/** Merge events from multiple relays, deduplicating by event id and collecting relay URLs. */ function deduplicateEvents(events: { event: NostrEvent; relayUrl: string }[]): EventWithRelay[] { const map = new Map(); for (const { event, relayUrl } of events) { @@ -47,11 +48,8 @@ function deduplicateEvents(events: { event: NostrEvent; relayUrl: string }[]): E return Array.from(map.values()); } -// Helper function to decode npub to hex pubkey function decodeAuthor(author: string): string { if (!author) return ''; - - // If it starts with npub, decode it if (author.startsWith('npub')) { try { const decoded = nip19.decode(author); @@ -59,33 +57,21 @@ function decodeAuthor(author: string): string { return decoded.data; } } catch { - // If decoding fails, return original return author; } } - - // Return as-is (assuming it's already hex) return author; } -// Helper function to normalize relay URL by adding wss:// protocol if none is present function normalizeRelayUrl(url: string): string { const trimmed = url.trim(); if (!trimmed) return trimmed; - - // Check if it already has a protocol - if (trimmed.includes('://')) { - return trimmed; - } - - // Always use wss:// for secure connections + if (trimmed.includes('://')) return trimmed; return `wss://${trimmed}`; } -// Helper function to validate WebSocket URL function isValidWebSocketUrl(url: string): boolean { if (!url || url.trim() === '') return false; - try { const normalizedUrl = normalizeRelayUrl(url); const urlObj = new URL(normalizedUrl); @@ -105,6 +91,8 @@ export function EventMonitor() { until: '', tags: [''] }); + const [mode, setMode] = useState<'search' | 'stream'>('search'); + const [queryType, setQueryType] = useState('kind'); const [isStreaming, setIsStreaming] = useState(false); const [streamPhase, setStreamPhase] = useState<'connecting' | 'historical' | 'live'>('connecting'); const [streamEvents, setStreamEvents] = useState([]); @@ -115,17 +103,44 @@ export function EventMonitor() { const [nipKinds, setNipKinds] = useState([]); const [nipMessage, setNipMessage] = useState(null); const [kindsExpanded, setKindsExpanded] = useState(false); + const [walkOpen, setWalkOpen] = useState(false); + const [eventRate, setEventRate] = useState(0); const nipActiveRef = useRef(false); const relayRef = useRef([]); const previousFiltersRef = useRef({}); const previousRelaysRef = useRef(filters.relays); + const rateWindowRef = useRef([]); const { isCopied, copyToClipboard } = useCopyToClipboard(); - // Memoize query filters to prevent unnecessary recalculations + // Walkthrough on first visit + useEffect(() => { + if (typeof window === 'undefined') return; + if (!localStorage.getItem(WALK_STORAGE_KEY)) { + setWalkOpen(true); + } + }, []); + + const closeWalkthrough = useCallback(() => { + setWalkOpen(false); + try { + localStorage.setItem(WALK_STORAGE_KEY, '1'); + } catch { + // ignore storage errors (private mode, etc.) + } + }, []); + + // Sync queryType with nipActiveRef — if user flips to kind, reset NIP state + useEffect(() => { + if (queryType === 'kind' && nipActiveRef.current) { + nipActiveRef.current = false; + setNipKinds([]); + setNipMessage(null); + } + }, [queryType]); + const queryFilters = useMemo(() => { const qf: NostrFilter = {}; - // If NIP filter is active, use nipKinds; otherwise use manual kinds if (nipActiveRef.current && nipKinds.length > 0) { qf.kinds = nipKinds; } else { @@ -135,7 +150,6 @@ export function EventMonitor() { } } - // Filter out empty strings and decode authors const validAuthors = filters.authors .filter(a => a.trim() !== '') .map(a => decodeAuthor(a)); @@ -143,15 +157,9 @@ export function EventMonitor() { qf.authors = validAuthors; } - if (filters.since) { - qf.since = parseInt(filters.since); - } - - if (filters.until) { - qf.until = parseInt(filters.until); - } + if (filters.since) qf.since = parseInt(filters.since); + if (filters.until) qf.until = parseInt(filters.until); - // Parse tags - each entry is "tagname:value" const validTags = filters.tags.filter(t => t.trim() !== ''); for (const tag of validTags) { const [tagName, tagValue] = tag.split(':').map(s => s.trim()); @@ -162,33 +170,27 @@ export function EventMonitor() { } } - if (filters.limit) { - qf.limit = parseInt(filters.limit, 10); - } + if (filters.limit) qf.limit = parseInt(filters.limit, 10); return qf; }, [filters.kinds, filters.authors, filters.since, filters.until, filters.tags, filters.limit, nipKinds]); - // Get valid relays const validRelays = useMemo(() => { return filters.relays .filter(r => r.trim() !== '' && isValidWebSocketUrl(r)) .map(r => normalizeRelayUrl(r)); }, [filters.relays]); - // Clear "Enter a relay first" message when relay becomes valid useEffect(() => { if (validRelays.length > 0 && nipMessage === 'Enter a relay first') { setNipMessage(null); } }, [validRelays.length, nipMessage]); - // Clear no-relay hint once a relay is added useEffect(() => { if (validRelays.length > 0) setNoRelayHint(null); }, [validRelays.length]); - // Query for limited events from multiple relays const { isLoading, refetch } = useQuery({ queryKey: ['events', validRelays, filters.kinds, filters.limit, filters.authors, filters.since, filters.until, filters.tags, nipKinds], queryFn: async (c) => { @@ -196,32 +198,22 @@ export function EventMonitor() { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]); - // Build query filters const qf: NostrFilter = {}; if (nipActiveRef.current && nipKinds.length > 0) { qf.kinds = nipKinds; } else { const validKinds = filters.kinds.filter(k => k.trim() !== '').map(k => parseInt(k)); - if (validKinds.length > 0) { - qf.kinds = validKinds; - } + if (validKinds.length > 0) qf.kinds = validKinds; } const validAuthors = filters.authors .filter(a => a.trim() !== '') .map(a => decodeAuthor(a)); - if (validAuthors.length > 0) { - qf.authors = validAuthors; - } - - if (filters.since) { - qf.since = parseInt(filters.since); - } + if (validAuthors.length > 0) qf.authors = validAuthors; - if (filters.until) { - qf.until = parseInt(filters.until); - } + if (filters.since) qf.since = parseInt(filters.since); + if (filters.until) qf.until = parseInt(filters.until); const validTags = filters.tags.filter(t => t.trim() !== ''); for (const tag of validTags) { @@ -233,21 +225,12 @@ export function EventMonitor() { } } - // Apply limit per relay - if (filters.limit) { - qf.limit = parseInt(filters.limit); - } else { - qf.limit = 50; - } + qf.limit = filters.limit ? parseInt(filters.limit) : 50; - // Query all relays in parallel const relayPromises = validRelays.map(async (relayUrl) => { const relay = new NRelay1(relayUrl); try { - console.log(`Querying ${relayUrl} with filters:`, qf); const events = await relay.query([qf], { signal }); - console.log(`Query result from ${relayUrl}:`, events.length, 'events'); - return events.map(event => ({ event, relayUrl })); } catch (error) { console.error(`Query failed for ${relayUrl}:`, error); @@ -260,18 +243,11 @@ export function EventMonitor() { try { const allResults = await Promise.all(relayPromises); const allTagged = allResults.flat(); - const allEvents = deduplicateEvents(allTagged); - console.log('Total unique events from all relays:', allEvents.length); - if (allEvents.length > 0) { - const kinds = [...new Set(allEvents.map(e => e.kind))]; - console.log('Event kinds found:', kinds); - } const sortedEvents = allEvents.sort((a, b) => b.created_at - a.created_at); setLastDisplayedEvents(sortedEvents); - // If NIP filter is active, populate kinds with found event kinds if (nipActiveRef.current && allEvents.length > 0) { const foundKinds = [...new Set(allEvents.map(e => e.kind))].sort((a, b) => a - b); const foundKindsStr = foundKinds.map(String); @@ -295,11 +271,10 @@ export function EventMonitor() { gcTime: 5 * 60 * 1000, }); - // Handle real-time streaming with req() subscriptions + // Streaming useEffect(() => { if (!isStreaming || validRelays.length === 0) return; - // Only clear events when filters or relays have actually changed const currentFiltersString = JSON.stringify(queryFilters); const previousFiltersString = JSON.stringify(previousFiltersRef.current); const relaysChanged = JSON.stringify(validRelays) !== JSON.stringify(previousRelaysRef.current); @@ -307,12 +282,10 @@ export function EventMonitor() { if (currentFiltersString !== previousFiltersString || relaysChanged) { setStreamEvents([]); setLastDisplayedEvents([]); - previousFiltersRef.current = { ...queryFilters }; previousRelaysRef.current = [...validRelays]; } - // Create relay connections for streaming const relays = validRelays.map(url => new NRelay1(url)); relayRef.current = relays; @@ -320,10 +293,6 @@ export function EventMonitor() { setStreamPhase('connecting'); setError(null); - console.log('Starting real-time stream with filters:', queryFilters); - console.log('Streaming from relays:', validRelays); - - // Shared mutable state for collecting events across relays const eventsMap = new Map(); const maxEvents = filters.limit ? parseInt(filters.limit, 10) : MAX_STREAM_EVENTS; const streamFilters: NostrFilter = { ...queryFilters, limit: maxEvents }; @@ -333,20 +302,16 @@ export function EventMonitor() { let nipKindsPopulated = false; let flushTimer: ReturnType | null = null; - // Synchronous flush — sorts eventsMap and pushes to React state const flushNow = () => { const sorted = Array.from(eventsMap.values()).sort((a, b) => b.created_at - a.created_at); const capped = sorted.slice(0, maxEvents); if (eventsMap.size > maxEvents) { eventsMap.clear(); - for (const event of capped) { - eventsMap.set(event.id, event); - } + for (const event of capped) eventsMap.set(event.id, event); } setStreamEvents(capped); setLastDisplayedEvents(capped); - // Populate NIP kinds once after first EOSE if (!nipKindsPopulated && nipActiveRef.current && capped.length > 0) { nipKindsPopulated = true; const foundKinds = [...new Set(capped.map(e => e.kind))].sort((a, b) => a - b); @@ -361,28 +326,27 @@ export function EventMonitor() { } }; - // Flush events to React state (throttled) const flushEvents = () => { - if (flushTimer) return; // already scheduled + if (flushTimer) return; flushTimer = setTimeout(() => { flushTimer = null; flushNow(); - }, 150); // batch updates every 150ms + }, 150); }; const addEvent = (event: NostrEvent, relayUrl: string) => { - const existing = eventsMap.get(event.id); if (existing) { - if (!existing.relayUrls.includes(relayUrl)) { - existing.relayUrls.push(relayUrl); - } + if (!existing.relayUrls.includes(relayUrl)) existing.relayUrls.push(relayUrl); } else { eventsMap.set(event.id, { ...event, relayUrls: [relayUrl] }); } + // Track rate (only after live phase) + if (allEoseReceived) { + rateWindowRef.current.push(Date.now()); + } }; - // Subscribe to each relay using req() const relayLoops = validRelays.map(async (relayUrl, index) => { const relay = relays[index]; try { @@ -393,25 +357,18 @@ export function EventMonitor() { if (msg[0] === 'EVENT') { const event = (msg as NostrRelayEVENT)[2]; addEvent(event, relayUrl); - // During historical phase, flush less aggressively (wait for EOSE) - if (allEoseReceived) { - flushEvents(); - } + if (allEoseReceived) flushEvents(); } else if (msg[0] === 'EOSE') { - console.log(`EOSE from ${relayUrl}`); eoseReceived.add(relayUrl); if (eoseReceived.size >= validRelays.length) { allEoseReceived = true; setStreamPhase('live'); - // Flush all historical events at once if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } flushEvents(); } else { setStreamPhase('historical'); } } else if (msg[0] === 'CLOSED') { - console.log(`Subscription closed by ${relayUrl}:`, msg[2]); - // Treat as terminal — count toward historical phase completion if (!eoseReceived.has(relayUrl)) { eoseReceived.add(relayUrl); if (eoseReceived.size >= validRelays.length) { @@ -427,7 +384,6 @@ export function EventMonitor() { } catch (error) { if (!controller.signal.aborted) { console.error(`Streaming error from ${relayUrl}:`, error); - // Treat as terminal — count toward historical phase completion if (!eoseReceived.has(relayUrl)) { eoseReceived.add(relayUrl); if (eoseReceived.size >= validRelays.length) { @@ -441,7 +397,6 @@ export function EventMonitor() { } }); - // Handle complete failure of all relays Promise.all(relayLoops).then(() => { if (!controller.signal.aborted && eventsMap.size === 0) { setError('All relay connections closed without receiving events.'); @@ -457,6 +412,79 @@ export function EventMonitor() { }; }, [isStreaming, validRelays, queryFilters, filters.limit]); + // Rolling rate calculation (events/sec over last 1s window) + useEffect(() => { + if (!isStreaming || streamPhase !== 'live') { + setEventRate(0); + rateWindowRef.current = []; + return; + } + const id = setInterval(() => { + const cutoff = Date.now() - 1000; + rateWindowRef.current = rateWindowRef.current.filter(t => t > cutoff); + setEventRate(rateWindowRef.current.length); + }, 500); + return () => clearInterval(id); + }, [isStreaming, streamPhase]); + + const resolveNipKinds = useCallback((): number[] | null => { + const validNips = nipFilter.filter(n => n.trim() !== ''); + if (validNips.length === 0) { + setNipMessage('Enter at least one NIP'); + return null; + } + + const notFound: string[] = []; + const noKinds: string[] = []; + const allKinds: number[] = []; + + for (const n of validNips) { + const nipInfo = getNipInfo(n.trim()); + if (!nipInfo) { + notFound.push(n.trim()); + continue; + } + const kinds = getKindsForNip(n.trim()); + if (kinds.length === 0) { + noKinds.push(n.trim()); + } + for (const k of kinds) { + if (!allKinds.includes(k)) allKinds.push(k); + } + } + allKinds.sort((a, b) => a - b); + + if (notFound.length > 0) { + nipActiveRef.current = false; + setNipKinds([]); + setNipMessage(`NIP-${notFound.join(', NIP-')} not found`); + return null; + } + + if (allKinds.length === 0) { + nipActiveRef.current = false; + setNipKinds([]); + const nipNames = noKinds.map(n => { + const info = getNipInfo(n); + return info ? `NIP-${n} (${info.name})` : `NIP-${n}`; + }); + setNipMessage(`${nipNames.join(', ')} — no associated event kinds`); + return null; + } + + if (noKinds.length > 0) { + setNipMessage(`NIP-${noKinds.join(', NIP-')} — no associated event kinds. Searching remaining NIPs.`); + } else { + setNipMessage(null); + } + + nipActiveRef.current = true; + setNipKinds(allKinds); + setFilters(prev => ({ ...prev, kinds: [''] })); + setKindsExpanded(false); + return allKinds; + }, [nipFilter]); + const handleSearch = useCallback(() => { if (validRelays.length === 0) { setNoRelayHint('search'); @@ -465,8 +493,14 @@ export function EventMonitor() { setNoRelayHint(null); setIsStreaming(false); setError(null); - refetch(); - }, [validRelays.length, refetch]); + + if (queryType === 'nip') { + const resolved = resolveNipKinds(); + if (!resolved) return; + } + + setTimeout(() => refetch(), 0); + }, [validRelays.length, refetch, queryType, resolveNipKinds]); const handleStream = useCallback(() => { if (validRelays.length === 0) { @@ -475,16 +509,29 @@ export function EventMonitor() { } setNoRelayHint(null); setError(null); + + if (queryType === 'nip') { + const resolved = resolveNipKinds(); + if (!resolved) return; + } + setIsStreaming(false); - // Re-trigger the streaming effect setTimeout(() => setIsStreaming(true), 0); - }, [validRelays.length]); + }, [validRelays.length, queryType, resolveNipKinds]); const handleSubmit = useCallback((e: React.FormEvent) => { e.preventDefault(); - handleSearch(); - }, [handleSearch]); - + if (mode === 'search') handleSearch(); + else handleStream(); + }, [mode, handleSearch, handleStream]); + + const handleModeChange = useCallback((newMode: 'search' | 'stream') => { + setMode((prev) => { + if (prev === newMode) return prev; + if (isStreaming) setIsStreaming(false); + return newMode; + }); + }, [isStreaming]); const displayEvents = useMemo(() => { if (isStreaming) { @@ -495,8 +542,7 @@ export function EventMonitor() { } return lastDisplayedEvents; }, [isStreaming, streamEvents, lastDisplayedEvents]); - - // Count active filters + const activeFilters = useMemo(() => { return [ (filters.kinds.some(k => k.trim() !== '') || nipActiveRef.current) && 'kind', @@ -504,11 +550,10 @@ export function EventMonitor() { filters.since && 'since', filters.until && 'until', filters.tags.some(t => t.trim() !== '') && 'tags', - nipFilter.some(n => n.trim() !== '') && 'nip' + queryType === 'nip' && nipFilter.some(n => n.trim() !== '') && 'nip' ].filter(Boolean).length; - }, [filters.kinds, filters.authors, filters.since, filters.until, filters.tags, nipFilter]); + }, [filters.kinds, filters.authors, filters.since, filters.until, filters.tags, nipFilter, queryType]); - // Calculate statistics per relay const relayStats = useMemo(() => { const stats = new Map(); displayEvents.forEach(event => { @@ -520,17 +565,140 @@ export function EventMonitor() { return stats; }, [displayEvents]); + const clearFilters = useCallback(() => { + setIsStreaming(false); + setFilters(prev => ({ + relays: prev.relays, + kinds: [''], + limit: '', + authors: [''], + since: '', + until: '', + tags: [''] + })); + nipActiveRef.current = false; + setNipFilter(['']); + setNipKinds([]); + setNipMessage(null); + setKindsExpanded(false); + }, []); + + const addSuggestedRelay = useCallback((host: string) => { + const url = `wss://${host}`; + setFilters(prev => { + if (prev.relays.some(r => r.trim() === url || r.trim() === host)) return prev; + const next = [...prev.relays]; + const firstEmpty = next.findIndex(r => r.trim() === ''); + if (firstEmpty >= 0) { + next[firstEmpty] = url; + } else { + next.push(url); + } + return { ...prev, relays: next }; + }); + }, []); + + const applyPreset = useCallback((p: QueryPreset) => { + setQueryType('kind'); + nipActiveRef.current = false; + setNipFilter(['']); + setNipKinds([]); + setNipMessage(null); + setFilters(prev => ({ ...prev, kinds: [p.kind] })); + setKindsExpanded(false); + }, []); + + const showEmptyState = + displayEvents.length === 0 && !isLoading && !isStreaming && !error; + + const streamingLabelSuffix = isStreaming + ? `(${displayEvents.length}${displayEvents.length >= (filters.limit ? parseInt(filters.limit, 10) : MAX_STREAM_EVENTS) ? ' -- cap reached' : ''})` + : `(${displayEvents.length})`; + return ( -
-
- - - - Nostr Events Monitor - - - +
+ {walkOpen && } + + {/* TOPBAR */} +
+
+
+ + 0 ? 'on' : 'off'}`} /> + {validRelays.length} relay{validRelays.length !== 1 ? 's' : ''} connected + + {isStreaming && ( + + + streaming{streamPhase === 'live' ? ` · ${eventRate}/s` : streamPhase === 'connecting' ? ' · connecting' : ' · loading'} + + )} + +
+
+
+ +
+ +
+ {/* Mode + Query-by segmented controls */} +
+
+ Mode +
+ + +
+
+ +
+ Query by +
+ + +
+
+ +
+ {queryType === 'nip' ? 'resolves NIPs → kinds' : 'direct event kind numbers'} +
+
+
{/* Relays */}
@@ -585,107 +753,154 @@ export function EventMonitor() {
- {/* Kinds */} -
- - - + {/* Kind OR NIP (depending on queryType) */} + {queryType === 'kind' ? (
- {(kindsExpanded ? filters.kinds : filters.kinds.slice(0, 3)).map((kind, index) => ( -
- { - const value = e.target.value; - if (value === '' || (!isNaN(Number(value)) && Number(value) >= 0)) { - const newKinds = [...filters.kinds]; - newKinds[index] = value; - setFilters(prev => ({ ...prev, kinds: newKinds })); - if (nipActiveRef.current) { - nipActiveRef.current = false; - setNipFilter(['']); - setNipKinds([]); - setNipMessage(null); + + + +
+ {(kindsExpanded ? filters.kinds : filters.kinds.slice(0, 3)).map((kind, index) => ( +
+ { + const value = e.target.value; + if (value === '' || (!isNaN(Number(value)) && Number(value) >= 0)) { + const newKinds = [...filters.kinds]; + newKinds[index] = value; + setFilters(prev => ({ ...prev, kinds: newKinds })); + if (nipActiveRef.current) { + nipActiveRef.current = false; + setNipKinds([]); + setNipMessage(null); + } } - } - }} - onKeyDown={(e) => { - if (e.key === '-' || e.key === 'e' || e.key === 'E') { - e.preventDefault(); - } - }} - className={`h-8 text-xs bg-background/50 border-accent/30 focus:border-accent/50 flex-1 ${kind ? 'border-accent/50 bg-accent/5' : ''}`} - /> - {index === 0 ? ( - - ) : ( - + ) : ( + + )} +
+ ))} + {filters.kinds.length > 3 && ( + + )} +
+
+ ) : ( +
+ + + +
+ {nipFilter.map((nip, index) => ( +
+ { + const value = e.target.value.toUpperCase(); + if (value === '' || /^[0-9A-F]+$/.test(value)) { + const newNips = [...nipFilter]; + newNips[index] = value; + setNipFilter(newNips); + if (nipMessage) setNipMessage(null); } }} - className="h-8 w-8 p-0 border-destructive/30 bg-transparent hover:bg-destructive/10" - > - - - )} -
- ))} - {filters.kinds.length > 3 && ( - - )} + className={`h-8 text-xs bg-background/50 border-accent/30 focus:border-accent/50 flex-1 ${nip ? 'border-accent/50 bg-accent/5' : ''}`} + /> + {index === 0 ? ( + + ) : ( + + )} +
+ ))} + {nipMessage && ( + {nipMessage} + )} +
-
+ )} {/* Authors */}
@@ -700,7 +915,6 @@ export function EventMonitor() {
{ @@ -715,7 +929,6 @@ export function EventMonitor() { type="button" variant="outline" size="sm" - onClick={() => setFilters(prev => ({ ...prev, authors: [...prev.authors, ''] }))} className="h-8 w-8 p-0 border-accent/30 bg-transparent hover:bg-accent/10" > @@ -726,7 +939,6 @@ export function EventMonitor() { type="button" variant="outline" size="sm" - onClick={() => { const newAuthors = filters.authors.filter((_, i) => i !== index); setFilters(prev => ({ ...prev, authors: newAuthors })); @@ -762,9 +974,7 @@ export function EventMonitor() { } }} onKeyDown={(e) => { - if (e.key === '-' || e.key === 'e' || e.key === 'E') { - e.preventDefault(); - } + if (e.key === '-' || e.key === 'e' || e.key === 'E') e.preventDefault(); }} className={`h-8 text-xs bg-background/50 border-accent/30 focus:border-accent/50 ${filters.limit ? 'border-accent/50 bg-accent/5' : ''}`} /> @@ -785,7 +995,6 @@ export function EventMonitor() { { @@ -800,7 +1009,6 @@ export function EventMonitor() { type="button" variant="outline" size="sm" - onClick={() => setFilters(prev => ({ ...prev, tags: [...prev.tags, ''] }))} className="h-8 w-8 p-0 border-accent/30 bg-transparent hover:bg-accent/10" > @@ -811,7 +1019,6 @@ export function EventMonitor() { type="button" variant="outline" size="sm" - onClick={() => { const newTags = filters.tags.filter((_, i) => i !== index); setFilters(prev => ({ ...prev, tags: newTags })); @@ -825,9 +1032,9 @@ export function EventMonitor() { ))}
- +
- @@ -858,7 +1065,7 @@ export function EventMonitor() {
- @@ -888,267 +1095,150 @@ export function EventMonitor() {
- -
-
- - - {noRelayHint === 'search' ? 'Enter a relay first' : 'Fetch once'} - -
-
- {isStreaming ? ( - - ) : ( + +
+ {mode === 'search' ? ( +
- )} - - {noRelayHint === 'stream' ? 'Enter a relay first' : isStreaming ? 'Stop streaming' : 'Real-time'} - -
+ + {noRelayHint === 'search' ? 'Enter a relay first' : 'Fetch once'} + +
+ ) : ( +
+ {isStreaming ? ( + + ) : ( + + )} + + {noRelayHint === 'stream' ? 'Enter a relay first' : isStreaming ? 'Stop streaming' : 'Real-time'} + +
+ )}
 
-
- -
- -
- {nipFilter.map((nip, index) => ( -
- { - const value = e.target.value.toUpperCase(); - if (value === '' || /^[0-9A-F]+$/.test(value)) { - const newNips = [...nipFilter]; - newNips[index] = value; - setNipFilter(newNips); - if (nipMessage) setNipMessage(null); - } - }} - className={`h-8 text-xs bg-background/50 border-accent/30 focus:border-accent/50 max-w-[200px] ${nip ? 'border-accent/50 bg-accent/5' : ''}`} - /> - {index === 0 ? ( - - ) : ( - - )} - {index === 0 && ( - - )} - {index === 0 && nipMessage && ( - {nipMessage} - )} -
- ))} +
+ {activeFilters} filter{activeFilters !== 1 ? 's' : ''} active + · + mode: {mode}/{queryType}
- + - - -
-
-
-

- Events {isStreaming - ? `(${displayEvents.length}${displayEvents.length >= (filters.limit ? parseInt(filters.limit, 10) : MAX_STREAM_EVENTS) ? ' -- cap reached' : ''})` - : `(${displayEvents.length})`} - {isStreaming && ( - - {streamPhase === 'connecting' && '-- Connecting...'} - {streamPhase === 'historical' && '-- Loading stored events...'} - {streamPhase === 'live' && '-- Live'} - - )} - {activeFilters > 0 && ( - - {isStreaming ? '| ' : '-- '}{activeFilters} filter{activeFilters !== 1 ? 's' : ''} active + {/* Results section */} +
+
+

+ Events {streamingLabelSuffix} + {isStreaming && ( + + {streamPhase === 'connecting' && '-- Connecting...'} + {streamPhase === 'historical' && '-- Loading stored events...'} + {streamPhase === 'live' && '-- Live'} + + )} + {activeFilters > 0 && ( + + {isStreaming ? '| ' : '-- '}{activeFilters} filter{activeFilters !== 1 ? 's' : ''} active + + )} +

+ {isLoading && Loading...} +
+ + {isStreaming && ( +
+ LIVE + + {streamPhase === 'connecting' && 'connecting'} + {streamPhase === 'historical' && 'loading history'} + {streamPhase === 'live' && 'live'} + + {streamPhase === 'live' && ( + + {eventRate} events/sec · {displayEvents.length} captured + + )} + +
+ )} + + {displayEvents.length > 0 && relayStats.size > 0 && ( +
+ {Array.from(relayStats.entries()).map(([relay, count]) => ( + + {relay.replace('wss://', '').replace('ws://', '')}: {count} event{count !== 1 ? 's' : ''} + + ))} +
+ )} + + {filters.kinds.some(k => k.trim() !== '') && ( +
+ {filters.kinds.filter(k => k.trim() !== '').map((k, index) => { + const kind = parseInt(k); + if (isNaN(kind)) return null; + const info = getKindInfo(kind); + return ( + + Event kind {kind}:{' '} + {info.link ? ( + + {info.nip} {info.description} + + ) : ( + {info.description} + )} + {' · '} + + {info.classification} + - )} -

- {isLoading && Loading...} + ); + })}
- {displayEvents.length > 0 && relayStats.size > 0 && ( -
- {Array.from(relayStats.entries()).map(([relay, count]) => ( - - {relay.replace('wss://', '').replace('ws://', '')}: {count} event{count !== 1 ? 's' : ''} - - ))} -
- )} - {filters.kinds.some(k => k.trim() !== '') && ( -
- {filters.kinds.filter(k => k.trim() !== '').map((k, index) => { - const kind = parseInt(k); - if (isNaN(kind)) return null; - const info = getKindInfo(kind); - return ( -
- Event kind {kind}:{' '} - {info.link ? ( - - {info.nip} {info.description} - - ) : ( - {info.description} - )} - . Event {info.classification} -
- ); - })} -
- )} -
+ )} {error && ( @@ -1158,7 +1248,6 @@ export function EventMonitor() { )} - {(isLoading || isStreaming) && displayEvents.length === 0 && ( @@ -1187,40 +1276,78 @@ export function EventMonitor() { )} - {displayEvents.length === 0 && !isLoading && !isStreaming && ( + {showEmptyState && validRelays.length === 0 && ( +
+
+

Enter a relay URL to start monitoring

+

+ Pick a relay below or paste your own wss:// endpoint in the Relay field above. + Then run a query or start streaming. +

+
+
// popular relays
+
+ {SUGGESTED_RELAYS.map((r) => ( + + ))} +
+
// query presets
+
+ {PRESETS.map((p) => ( + + ))} +
+
+
+ )} + + {showEmptyState && validRelays.length > 0 && ( -

- {validRelays.length > 0 ? 'No events found' : 'Enter a relay URL to start monitoring'} -

- {validRelays.length > 0 && ( -
-

Connected to {validRelays.length} relay{validRelays.length !== 1 ? 's' : ''}:

-
- {validRelays.map((relay, idx) => ( - {relay} - ))} -
-

Searching for event kinds: - {filters.kinds.filter(k => k.trim() !== '').join(', ') || 'all kinds'} -

-
-

💡 Troubleshooting tips:

-
    -
  • • Try setting a specific Kind (e.g., 1 for notes)
  • -
  • • Check if your relay has any events stored
  • -
  • • Try removing time filters (Since/Until)
  • -
  • • Publish a test event to your relay
  • -
-
+

No events found

+
+

Connected to {validRelays.length} relay{validRelays.length !== 1 ? 's' : ''}:

+
+ {validRelays.map((relay, idx) => ( + {relay} + ))}
- )} +

Searching for event kinds: + {filters.kinds.filter(k => k.trim() !== '').join(', ') || 'all kinds'} +

+
+

💡 Troubleshooting tips:

+
    +
  • • Try setting a specific Kind (e.g., 1 for notes)
  • +
  • • Check if your relay has any events stored
  • +
  • • Try removing time filters (Since/Until)
  • +
  • • Publish a test event to your relay
  • +
+
+
)} {displayEvents.map((event, index) => ( - +
{event.relayUrls.length > 0 && (
@@ -1228,7 +1355,7 @@ export function EventMonitor() { {url.replace('wss://', '').replace('ws://', '')} @@ -1239,7 +1366,7 @@ export function EventMonitor() { variant="ghost" size="sm" onClick={() => copyToClipboard(JSON.stringify(event, null, 2))} - className="h-8 w-8 p-0 opacity-50 hover:opacity-100 transition-all duration-200 hover:scale-110 active:scale-95 bg-background/80 backdrop-blur-sm border border-border/50 rounded-full shadow-sm shrink-0" + className="h-8 w-8 p-0 opacity-50 hover:opacity-100 transition-opacity duration-200 bg-background/80 border border-border/50 rounded-full shadow-sm shrink-0" aria-label="Copy event to clipboard" > {isCopied ? ( @@ -1258,7 +1385,7 @@ export function EventMonitor() {

- Vibed by{" "} + Vibed by{' '}

-
); -} \ No newline at end of file +}