From 6560dc203c3af2519c4d9a5c1ee5ad2d9a1ec0b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sun, 14 Dec 2025 15:34:07 +0100 Subject: [PATCH] ui: query viewer improvements --- package-lock.json | 145 +++++++ package.json | 2 + src/components/JsonViewer.tsx | 29 +- src/components/ReqViewer.tsx | 618 +++++++++++++++++++++++------- src/components/ui/accordion.tsx | 55 +++ src/components/ui/collapsible.tsx | 9 + tailwind.config.js | 14 + 7 files changed, 726 insertions(+), 146 deletions(-) create mode 100644 src/components/ui/accordion.tsx create mode 100644 src/components/ui/collapsible.tsx diff --git a/package-lock.json b/package-lock.json index 809ef36..9b878a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,10 @@ "name": "grimoire", "version": "0.1.0", "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", @@ -1591,6 +1593,78 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -1768,6 +1842,77 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", diff --git a/package.json b/package.json index 1fc927f..5573605 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,10 @@ "test:run": "vitest run" }, "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", diff --git a/src/components/JsonViewer.tsx b/src/components/JsonViewer.tsx index 2697087..9641f3a 100644 --- a/src/components/JsonViewer.tsx +++ b/src/components/JsonViewer.tsx @@ -1,4 +1,4 @@ -import { Check, Copy } from "lucide-react"; +import { CopyCheck, Copy } from "lucide-react"; import { Dialog, DialogContent, @@ -33,30 +33,23 @@ export function JsonViewer({ - - {title} + {title} + +
+
             
-          
-        
-        
-
             {jsonString}
           
diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index 6e3e15a..f9702fc 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -15,14 +15,21 @@ import { Shield, Filter as FilterIcon, Download, + Copy, + Clock, + User, + Hash, + Search, + Code, + CopyCheck, } from "lucide-react"; import { Virtuoso } from "react-virtuoso"; import { useReqTimeline } from "@/hooks/useReqTimeline"; import { useGrimoire } from "@/core/state"; -import { useProfile } from "@/hooks/useProfile"; import { useRelayState } from "@/hooks/useRelayState"; import { FeedEvent } from "./nostr/Feed"; import { KindBadge } from "./KindBadge"; +import { UserName } from "./nostr/UserName"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { DropdownMenu, @@ -40,6 +47,17 @@ import { import { Input } from "./ui/input"; import { Button } from "./ui/button"; import { Progress } from "./ui/progress"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "./ui/collapsible"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "./ui/accordion"; import { RelayLink } from "./nostr/RelayLink"; import type { NostrFilter } from "@/types/nostr"; import type { RelayState } from "@/types/relay-state"; @@ -48,10 +66,10 @@ import { formatDTags, formatTimeRange, formatGenericTag, - formatPubkeysWithProfiles, formatHashtags, } from "@/lib/filter-formatters"; import { sanitizeFilename } from "@/lib/filename-utils"; +import { useCopy } from "@/hooks/useCopy"; // Memoized FeedEvent to prevent unnecessary re-renders during scroll const MemoizedFeedEvent = memo( @@ -138,21 +156,18 @@ interface QueryDropdownProps { nip05PTags?: string[]; } -function QueryDropdown({ - filter, - nip05Authors, - nip05PTags, -}: QueryDropdownProps) { - // Load profiles for authors and #p tags - const authorPubkeys = filter.authors || []; - const authorProfiles = authorPubkeys - .slice(0, 10) - .map((pubkey) => useProfile(pubkey)); +function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) { + const { copy: handleCopy, copied } = useCopy(); + // Expandable lists state + const [showAllAuthors, setShowAllAuthors] = useState(false); + const [showAllPTags, setShowAllPTags] = useState(false); + const [showAllETags, setShowAllETags] = useState(false); + const [showAllTTags, setShowAllTTags] = useState(false); + + // Get pubkeys for authors and #p tags + const authorPubkeys = filter.authors || []; const pTagPubkeys = filter["#p"] || []; - const pTagProfiles = pTagPubkeys - .slice(0, 10) - .map((pubkey) => useProfile(pubkey)); // Extract tag filters const eTags = filter["#e"]; @@ -169,139 +184,480 @@ function QueryDropdown({ ) .map(([key, values]) => ({ letter: key[1], values: values as string[] })); + // Calculate summary counts + const tagCount = + (eTags?.length || 0) + + (pTagPubkeys.length || 0) + + (tTags?.length || 0) + + (dTags?.length || 0) + + genericTags.reduce((sum, tag) => sum + tag.values.length, 0); + + // Determine if we should use accordion for complex queries + const isComplexQuery = + (filter.kinds?.length || 0) + + authorPubkeys.length + + (filter.search ? 1 : 0) + + tagCount > + 5; + return ( -
- {/* Kinds */} - {filter.kinds && filter.kinds.length > 0 && ( -
- Kinds: - {filter.kinds.map((kind) => ( - - ))} -
- )} - - {/* Time Range */} - {(filter.since || filter.until) && ( -
- - Time Range: +
+ {/* Summary Header */} +
+ {filter.kinds && filter.kinds.length > 0 && ( + + + {filter.kinds.length} kind{filter.kinds.length !== 1 ? "s" : ""} - - {formatTimeRange(filter.since, filter.until)} + )} + {authorPubkeys.length > 0 && ( + + + {authorPubkeys.length} author + {authorPubkeys.length !== 1 ? "s" : ""} -
- )} - - {/* Search */} - {filter.search && ( -
- Search: - "{filter.search}" -
- )} - - {/* Authors */} - {authorPubkeys.length > 0 && ( -
- - Authors: {authorPubkeys.length} + )} + {(filter.since || filter.until) && ( + + + time range -
- {formatPubkeysWithProfiles(authorPubkeys, authorProfiles, 3)} -
- {nip05Authors && nip05Authors.length > 0 && ( -
- {nip05Authors.map((nip05) => ( -
→ {nip05}
- ))} -
- )} -
- )} - - {/* Tag Filters Section */} - {(eTags || - pTagPubkeys.length > 0 || - tTags || - dTags || - genericTags.length > 0) && ( -
- - Tag Filters: + )} + {filter.search && ( + + + search + )} + {tagCount > 0 && ( + + + {tagCount} tag{tagCount !== 1 ? "s" : ""} + + )} +
- {/* Event References (#e) */} - {eTags && eTags.length > 0 && ( -
- #e ({eTags.length}): - {formatEventIds(eTags, 3)} -
- )} - - {/* Mentions (#p) */} - {pTagPubkeys.length > 0 && ( -
- #p ({pTagPubkeys.length}): - - {formatPubkeysWithProfiles(pTagPubkeys, pTagProfiles, 3)} - - {nip05PTags && nip05PTags.length > 0 && ( -
- {nip05PTags.map((nip05) => ( -
→ {nip05}
+ {isComplexQuery ? ( + /* Accordion for complex queries */ + + {/* Kinds Section */} + {filter.kinds && filter.kinds.length > 0 && ( + + +
+ + Kinds ({filter.kinds.length}) +
+
+ +
+ {filter.kinds.map((kind) => ( + ))}
- )} +
+
+ )} + + {/* Time Range Section */} + {(filter.since || filter.until) && ( + + +
+ + Time Range +
+
+ +
+ {formatTimeRange(filter.since, filter.until)} +
+
+
+ )} + + {/* Search Section */} + {filter.search && ( + + +
+ + Search +
+
+ +
+ + "{filter.search}" + +
+
+
+ )} + + {/* Authors Section */} + {authorPubkeys.length > 0 && ( + + +
+ + Authors ({authorPubkeys.length}) +
+
+ +
+
+ {authorPubkeys + .slice(0, showAllAuthors ? undefined : 3) + .map((pubkey) => { + return ( + + ); + })} +
+ {authorPubkeys.length > 3 && ( + + )} + {nip05Authors && nip05Authors.length > 0 && ( +
+ {nip05Authors.map((nip05) => ( +
→ {nip05}
+ ))} +
+ )} +
+
+
+ )} + + {/* Tags Section */} + {tagCount > 0 && ( + + +
+ + Tags ({tagCount}) +
+
+ +
+ {/* Event References (#e) */} + {eTags && eTags.length > 0 && ( +
+
+ Event References ({eTags.length}) +
+
+ {eTags + .slice(0, showAllETags ? undefined : 3) + .map((eventId) => ( +
+ + {eventId.slice(0, 8)}...{eventId.slice(-4)} + +
+ ))} +
+ {eTags.length > 3 && ( + + )} +
+ )} + + {/* Mentions (#p) */} + {pTagPubkeys.length > 0 && ( +
+
+ Mentions ({pTagPubkeys.length}) +
+
+ {pTagPubkeys + .slice(0, showAllPTags ? undefined : 3) + .map((pubkey) => { + return ( + + ); + })} +
+ {pTagPubkeys.length > 3 && ( + + )} +
+ )} + + {/* Hashtags (#t) */} + {tTags && tTags.length > 0 && ( +
+
+ Hashtags ({tTags.length}) +
+
+ {tTags + .slice(0, showAllTTags ? undefined : 5) + .map((tag) => ( + + #{tag} + + ))} +
+ {tTags.length > 5 && ( + + )} +
+ )} + + {/* D-Tags (#d) */} + {dTags && dTags.length > 0 && ( +
+
+ D-Tags ({dTags.length}) +
+
+ {formatDTags(dTags, 5)} +
+
+ )} + + {/* Generic Tags */} + {genericTags.map((tag) => ( +
+
+ #{tag.letter} Tags ({tag.values.length}) +
+
+ {formatGenericTag(tag.letter, tag.values, 5).replace( + `#${tag.letter}: `, + "", + )} +
+
+ ))} +
+
+
+ )} +
+ ) : ( + /* Simple cards for simple queries */ +
+ {/* Kinds */} + {filter.kinds && filter.kinds.length > 0 && ( +
+
+ + Kinds ({filter.kinds.length}) +
+
+ {filter.kinds.map((kind) => ( + + ))} +
)} - {/* Hashtags (#t) */} - {tTags && tTags.length > 0 && ( -
- #t ({tTags.length}): - {formatHashtags(tTags, 3)} + {/* Time Range */} + {(filter.since || filter.until) && ( +
+
+ + Time Range +
+
+ {formatTimeRange(filter.since, filter.until)} +
)} - {/* D-Tags (#d) */} - {dTags && dTags.length > 0 && ( -
- #d ({dTags.length}): - {formatDTags(dTags, 3)} + {/* Search */} + {filter.search && ( +
+
+ + Search +
+
+ + "{filter.search}" + +
)} - {/* Generic Tags */} - {genericTags.map((tag) => ( -
- - #{tag.letter} ({tag.values.length}): - - - {formatGenericTag(tag.letter, tag.values, 3).replace( - `#${tag.letter}: `, - "", + {/* Authors */} + {authorPubkeys.length > 0 && ( +
+
+ + Authors ({authorPubkeys.length}) +
+
+
+ {authorPubkeys + .slice(0, showAllAuthors ? undefined : 3) + .map((pubkey) => { + return ( + + ); + })} +
+ {authorPubkeys.length > 3 && ( + )} - +
- ))} + )} + + {/* Tags (simplified for simple queries) */} + {tagCount > 0 && ( +
+
+ + Tags ({tagCount}) +
+
+ {eTags && eTags.length > 0 && ( +
Event refs: {formatEventIds(eTags, 3)}
+ )} + {pTagPubkeys.length > 0 && ( +
+ Mentions: + {pTagPubkeys.slice(0, 3).map((pubkey, idx) => ( + + + {idx < Math.min(2, pTagPubkeys.length - 1) && ","} + + ))} + {pTagPubkeys.length > 3 && ( + ...+{pTagPubkeys.length - 3} more + )} +
+ )} + {tTags && tTags.length > 0 && ( +
Hashtags: {formatHashtags(tTags, 3)}
+ )} + {dTags && dTags.length > 0 && ( +
D-tags: {formatDTags(dTags, 3)}
+ )} + {genericTags.map((tag) => ( +
+ #{tag.letter}:{" "} + {formatGenericTag(tag.letter, tag.values, 3).replace( + `#${tag.letter}: `, + "", + )} +
+ ))} +
+
+ )}
)} - {/* Raw Query */} -
- Show raw query -
-          {JSON.stringify(filter, null, 2)}
-        
-
+ {/* Raw Query - Always at bottom */} + + + + Raw Query JSON + + + +
+
+              
+              {JSON.stringify(filter, null, 2)}
+            
+
+
+
); } @@ -383,7 +739,9 @@ export default function ReqViewer({ chunks.push(jsonlChunk); // Update progress - setExportProgress(Math.round(((i + chunk.length) / events.length) * 100)); + setExportProgress( + Math.round(((i + chunk.length) / events.length) * 100), + ); } // Join chunks with newlines between them @@ -651,7 +1009,11 @@ export default function ReqViewer({ placeholder="Enter filename" disabled={isExporting} onKeyDown={(e) => { - if (e.key === "Enter" && exportFilename.trim() && !isExporting) { + if ( + e.key === "Enter" && + exportFilename.trim() && + !isExporting + ) { handleExport(); } }} diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..0877c8e --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,55 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..20567db --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.Trigger; + +const CollapsibleContent = CollapsiblePrimitive.Content; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/tailwind.config.js b/tailwind.config.js index 95a69fb..f38162f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -15,6 +15,20 @@ export default { md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)' }, + keyframes: { + 'accordion-down': { + from: { height: '0' }, + to: { height: 'var(--radix-accordion-content-height)' } + }, + 'accordion-up': { + from: { height: 'var(--radix-accordion-content-height)' }, + to: { height: '0' } + } + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out' + }, colors: { background: 'hsl(var(--background))', foreground: 'hsl(var(--foreground))',