SearchResultsView.svelte raw
1 <script>
2 export let searchTab = null;
3 export let searchResults = new Map();
4 export let expandedEvents = new Set();
5 export let userRole = "";
6 export let userPubkey = "";
7
8 import { createEventDispatcher } from "svelte";
9 const dispatch = createEventDispatcher();
10
11 function loadSearchResults(tabId, query, refresh) {
12 dispatch("loadSearchResults", { tabId, query, refresh });
13 }
14
15 function handleSearchScroll(event, tabId) {
16 dispatch("searchScroll", { event, tabId });
17 }
18
19 function toggleEventExpansion(eventId) {
20 dispatch("toggleEventExpansion", eventId);
21 }
22
23 function deleteEvent(eventId) {
24 dispatch("deleteEvent", eventId);
25 }
26
27 function copyEventToClipboard(event, e) {
28 dispatch("copyEventToClipboard", { event, e });
29 }
30
31 function truncatePubkey(pubkey) {
32 if (!pubkey) return "";
33 return pubkey.slice(0, 8) + "..." + pubkey.slice(-8);
34 }
35
36 function getKindName(kind) {
37 const kindNames = {
38 0: "Profile",
39 1: "Text Note",
40 2: "Recommend Relay",
41 3: "Contacts",
42 4: "Encrypted DM",
43 5: "Delete",
44 6: "Repost",
45 7: "Reaction",
46 8: "Badge Award",
47 16: "Generic Repost",
48 40: "Channel Creation",
49 41: "Channel Metadata",
50 42: "Channel Message",
51 43: "Channel Hide Message",
52 44: "Channel Mute User",
53 1984: "Reporting",
54 9734: "Zap Request",
55 9735: "Zap",
56 10000: "Mute List",
57 10001: "Pin List",
58 10002: "Relay List",
59 22242: "Client Auth",
60 24133: "Nostr Connect",
61 27235: "HTTP Auth",
62 30000: "Categorized People",
63 30001: "Categorized Bookmarks",
64 30008: "Profile Badges",
65 30009: "Badge Definition",
66 30017: "Create or update a stall",
67 30018: "Create or update a product",
68 30023: "Long-form Content",
69 30024: "Draft Long-form Content",
70 30078: "Application-specific Data",
71 30311: "Live Event",
72 30315: "User Statuses",
73 30402: "Classified Listing",
74 30403: "Draft Classified Listing",
75 31922: "Date-Based Calendar Event",
76 31923: "Time-Based Calendar Event",
77 31924: "Calendar",
78 31925: "Calendar Event RSVP",
79 31989: "Handler recommendation",
80 31990: "Handler information",
81 34550: "Community Definition",
82 };
83 return kindNames[kind] || `Kind ${kind}`;
84 }
85
86 function formatTimestamp(timestamp) {
87 return new Date(timestamp * 1000).toLocaleString();
88 }
89
90 function truncateContent(content) {
91 if (!content) return "";
92 return content.length > 100 ? content.slice(0, 100) + "..." : content;
93 }
94 </script>
95
96 {#if searchTab}
97 <div class="search-results-view">
98 <div class="search-results-header">
99 <h2>🔍 Search Results: "{searchTab.query}"</h2>
100 <button
101 class="refresh-btn"
102 on:click={() =>
103 loadSearchResults(searchTab.id, searchTab.query, true)}
104 disabled={searchResults.get(searchTab.id)?.isLoading}
105 >
106 🔄 Refresh
107 </button>
108 </div>
109 <div
110 class="search-results-content"
111 on:scroll={(e) => handleSearchScroll(e, searchTab.id)}
112 >
113 {#if searchResults.get(searchTab.id)?.events?.length > 0}
114 {#each searchResults.get(searchTab.id).events as event}
115 <div
116 class="search-result-item"
117 class:expanded={expandedEvents.has(event.id)}
118 >
119 <div
120 class="search-result-row"
121 on:click={() => toggleEventExpansion(event.id)}
122 on:keydown={(e) =>
123 e.key === "Enter" &&
124 toggleEventExpansion(event.id)}
125 role="button"
126 tabindex="0"
127 >
128 <div class="search-result-avatar">
129 <div class="avatar-placeholder">👤</div>
130 </div>
131 <div class="search-result-info">
132 <div class="search-result-author">
133 {truncatePubkey(event.pubkey)}
134 </div>
135 <div class="search-result-kind">
136 <span class="kind-number">{event.kind}</span
137 >
138 <span class="kind-name"
139 >{getKindName(event.kind)}</span
140 >
141 </div>
142 </div>
143 <div class="search-result-content">
144 <div class="event-timestamp">
145 {formatTimestamp(event.created_at)}
146 </div>
147 <div class="event-content-single-line">
148 {truncateContent(event.content)}
149 </div>
150 </div>
151 {#if userRole === "admin" || userRole === "owner" || (userRole === "write" && event.pubkey && event.pubkey === userPubkey)}
152 <button
153 class="delete-btn"
154 on:click|stopPropagation={() =>
155 deleteEvent(event.id)}
156 >
157 🗑️
158 </button>
159 {/if}
160 </div>
161 {#if expandedEvents.has(event.id)}
162 <div class="search-result-details">
163 <div class="json-container">
164 <pre class="event-json">{JSON.stringify(
165 event,
166 null,
167 2,
168 )}</pre>
169 <button
170 class="copy-json-btn"
171 on:click|stopPropagation={(e) =>
172 copyEventToClipboard(event, e)}
173 title="Copy minified JSON to clipboard"
174 >
175 📋
176 </button>
177 </div>
178 </div>
179 {/if}
180 </div>
181 {/each}
182 {:else if !searchResults.get(searchTab.id)?.isLoading}
183 <div class="no-results">
184 <p>No results found for "{searchTab.query}"</p>
185 </div>
186 {/if}
187
188 {#if searchResults.get(searchTab.id)?.isLoading}
189 <div class="loading-search">
190 <div class="spinner"></div>
191 <p>Searching...</p>
192 </div>
193 {/if}
194 </div>
195 </div>
196 {/if}
197
198 <style>
199 .search-results-view {
200 width: 100%;
201 height: 100%;
202 display: flex;
203 flex-direction: column;
204 }
205
206 .search-results-header {
207 display: flex;
208 justify-content: space-between;
209 align-items: center;
210 padding: 1em;
211 border-bottom: 1px solid var(--border-color);
212 background: var(--header-bg);
213 }
214
215 .search-results-header h2 {
216 margin: 0;
217 color: var(--text-color);
218 font-size: 1.2rem;
219 font-weight: 600;
220 }
221
222 .refresh-btn {
223 background: var(--primary);
224 color: var(--text-color);
225 border: none;
226 padding: 0.5em 1em;
227 border-radius: 4px;
228 cursor: pointer;
229 font-size: 0.9em;
230 transition: background-color 0.2s;
231 }
232
233 .refresh-btn:hover:not(:disabled) {
234 background: var(--accent-hover-color);
235 }
236
237 .refresh-btn:disabled {
238 background: var(--secondary);
239 cursor: not-allowed;
240 }
241
242 .search-results-content {
243 flex: 1;
244 overflow-y: auto;
245 padding: 1em;
246 }
247
248 .search-result-item {
249 border: 1px solid var(--border-color);
250 border-radius: 8px;
251 margin-bottom: 0.5em;
252 background: var(--card-bg);
253 transition: all 0.2s ease;
254 }
255
256 .search-result-item:hover {
257 border-color: var(--primary);
258 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
259 }
260
261 .search-result-item.expanded {
262 border-color: var(--primary);
263 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
264 }
265
266 .search-result-row {
267 display: flex;
268 align-items: center;
269 padding: 1em;
270 cursor: pointer;
271 gap: 1em;
272 }
273
274 .search-result-avatar {
275 flex-shrink: 0;
276 }
277
278 .avatar-placeholder {
279 width: 40px;
280 height: 40px;
281 border-radius: 50%;
282 background: var(--bg-color);
283 display: flex;
284 align-items: center;
285 justify-content: center;
286 font-size: 1.2em;
287 border: 1px solid var(--border-color);
288 }
289
290 .search-result-info {
291 flex-shrink: 0;
292 min-width: 120px;
293 }
294
295 .search-result-author {
296 font-weight: 600;
297 color: var(--text-color);
298 font-size: 0.9em;
299 font-family: monospace;
300 }
301
302 .search-result-kind {
303 display: flex;
304 align-items: center;
305 gap: 0.5em;
306 margin-top: 0.25em;
307 }
308
309 .kind-number {
310 background: var(--primary);
311 color: var(--text-color);
312 padding: 0.1em 0.4em;
313 border-radius: 0.25rem;
314 font-size: 0.7em;
315 font-weight: 600;
316 font-family: monospace;
317 }
318
319 .kind-name {
320 font-size: 0.8em;
321 color: var(--text-color);
322 opacity: 0.8;
323 }
324
325 .search-result-content {
326 flex: 1;
327 min-width: 0;
328 }
329
330 .event-timestamp {
331 font-size: 0.8em;
332 color: var(--text-color);
333 opacity: 0.6;
334 margin-bottom: 0.5em;
335 }
336
337 .event-content-single-line {
338 color: var(--text-color);
339 line-height: 1.4;
340 word-wrap: break-word;
341 }
342
343 .delete-btn {
344 background: var(--danger);
345 color: var(--text-color);
346 border: none;
347 padding: 0.5em;
348 border-radius: 4px;
349 cursor: pointer;
350 font-size: 0.9em;
351 flex-shrink: 0;
352 transition: background-color 0.2s;
353 }
354
355 .delete-btn:hover {
356 background: var(--danger);
357 filter: brightness(0.9);
358 }
359
360 .search-result-details {
361 border-top: 1px solid var(--border-color);
362 padding: 1em;
363 background: var(--bg-color);
364 }
365
366 .json-container {
367 position: relative;
368 }
369
370 .event-json {
371 background: var(--code-bg);
372 padding: 1em;
373 border: 0;
374 font-size: 0.8em;
375 line-height: 1.4;
376 overflow-x: auto;
377 margin: 0;
378 color: var(--code-text);
379 }
380
381 .copy-json-btn {
382 position: absolute;
383 top: 0.5em;
384 right: 0.5em;
385 background: var(--primary);
386 color: var(--text-color);
387 border: none;
388 padding: 0.25em 0.5em;
389 border-radius: 0.25rem;
390 cursor: pointer;
391 font-size: 0.8em;
392 opacity: 0.8;
393 transition: opacity 0.2s;
394 }
395
396 .copy-json-btn:hover {
397 opacity: 1;
398 }
399
400 .no-results {
401 text-align: center;
402 padding: 2em;
403 color: var(--text-color);
404 opacity: 0.7;
405 }
406
407 .loading-search {
408 text-align: center;
409 padding: 2em;
410 color: var(--text-color);
411 }
412
413 .spinner {
414 width: 20px;
415 height: 20px;
416 border: 2px solid var(--border-color);
417 border-top: 2px solid var(--primary);
418 border-radius: 50%;
419 animation: spin 1s linear infinite;
420 margin: 0 auto 1em;
421 }
422
423 @keyframes spin {
424 0% {
425 transform: rotate(0deg);
426 }
427 100% {
428 transform: rotate(360deg);
429 }
430 }
431 </style>
432