EventsView.svelte raw
1 <script>
2 export let isLoggedIn = false;
3 export let userRole = "";
4 export let userPubkey = "";
5 export let filteredEvents = [];
6 export let expandedEvents = new Set();
7 export let isLoadingEvents = false;
8 export let showOnlyMyEvents = false;
9 export let showFilterBuilder = false;
10
11 import { createEventDispatcher } from "svelte";
12 import FilterBuilder from "./FilterBuilder.svelte";
13 const dispatch = createEventDispatcher();
14
15 // Local state for JSON editor toggle
16 let showJsonEditor = false;
17
18 function handleScroll(event) {
19 dispatch("scroll", event);
20 }
21
22 function toggleEventExpansion(eventId) {
23 dispatch("toggleEventExpansion", eventId);
24 }
25
26 function deleteEvent(eventId) {
27 dispatch("deleteEvent", eventId);
28 }
29
30 function copyEventToClipboard(event, e) {
31 dispatch("copyEventToClipboard", { event, e });
32 }
33
34 function handleToggleChange() {
35 dispatch("toggleChange");
36 }
37
38 function loadAllEvents(refresh, authors) {
39 dispatch("loadAllEvents", { refresh, authors });
40 }
41
42 function toggleFilterBuilder() {
43 dispatch("toggleFilterBuilder");
44 }
45
46 function toggleJsonEditor() {
47 showJsonEditor = !showJsonEditor;
48 }
49
50 function handleFilterApply(event) {
51 dispatch("filterApply", event.detail);
52 }
53
54 function handleFilterClear() {
55 dispatch("filterClear");
56 }
57
58 function truncatePubkey(pubkey) {
59 if (!pubkey) return "";
60 return pubkey.slice(0, 8) + "..." + pubkey.slice(-8);
61 }
62
63 function getKindName(kind) {
64 const kindNames = {
65 0: "Profile",
66 1: "Text Note",
67 2: "Recommend Relay",
68 3: "Contacts",
69 4: "Encrypted DM",
70 5: "Delete",
71 6: "Repost",
72 7: "Reaction",
73 8: "Badge Award",
74 16: "Generic Repost",
75 40: "Channel Creation",
76 41: "Channel Metadata",
77 42: "Channel Message",
78 43: "Channel Hide Message",
79 44: "Channel Mute User",
80 1984: "Reporting",
81 9734: "Zap Request",
82 9735: "Zap",
83 10000: "Mute List",
84 10001: "Pin List",
85 10002: "Relay List",
86 22242: "Client Auth",
87 24133: "Nostr Connect",
88 27235: "HTTP Auth",
89 30000: "Categorized People",
90 30001: "Categorized Bookmarks",
91 30008: "Profile Badges",
92 30009: "Badge Definition",
93 30017: "Create or update a stall",
94 30018: "Create or update a product",
95 30023: "Long-form Content",
96 30024: "Draft Long-form Content",
97 30078: "Application-specific Data",
98 30311: "Live Event",
99 30315: "User Statuses",
100 30402: "Classified Listing",
101 30403: "Draft Classified Listing",
102 31922: "Date-Based Calendar Event",
103 31923: "Time-Based Calendar Event",
104 31924: "Calendar",
105 31925: "Calendar Event RSVP",
106 31989: "Handler recommendation",
107 31990: "Handler information",
108 34550: "Community Definition",
109 };
110 return kindNames[kind] || `Kind ${kind}`;
111 }
112
113 function formatTimestamp(timestamp) {
114 return new Date(timestamp * 1000).toLocaleString();
115 }
116
117 function truncateContent(content) {
118 if (!content) return "";
119 return content.length > 100 ? content.slice(0, 100) + "..." : content;
120 }
121 </script>
122
123 <div class="events-view-container">
124 <div class="events-view-content" on:scroll={handleScroll}>
125 {#if filteredEvents.length > 0}
126 {#each filteredEvents as event}
127 <div
128 class="events-view-item"
129 class:expanded={expandedEvents.has(event.id)}
130 >
131 <div
132 class="events-view-row"
133 on:click={() => toggleEventExpansion(event.id)}
134 on:keydown={(e) =>
135 e.key === "Enter" &&
136 toggleEventExpansion(event.id)}
137 role="button"
138 tabindex="0"
139 >
140 <div class="events-view-avatar">
141 <div class="avatar-placeholder">👤</div>
142 </div>
143 <div class="events-view-info">
144 <div class="events-view-author">
145 {truncatePubkey(event.pubkey)}
146 </div>
147 <div class="events-view-kind">
148 <span
149 class="kind-number"
150 class:delete-event={event.kind === 5}
151 >{event.kind}</span
152 >
153 <span class="kind-name"
154 >{getKindName(event.kind)}</span
155 >
156 </div>
157 </div>
158 <div class="events-view-content">
159 <div class="event-timestamp">
160 {formatTimestamp(event.created_at)}
161 </div>
162 {#if event.kind === 5}
163 <div class="delete-event-info">
164 <span class="delete-event-label"
165 >🗑️ Delete Event</span
166 >
167 {#if event.tags && event.tags.length > 0}
168 <div class="delete-targets">
169 {#each event.tags.filter((tag) => tag[0] === "e") as eTag}
170 <span class="delete-target"
171 >Target: {eTag[1].slice(
172 0,
173 8,
174 )}...{eTag[1].slice(
175 -8,
176 )}</span
177 >
178 {/each}
179 </div>
180 {/if}
181 </div>
182 {:else}
183 <div class="event-content-single-line">
184 {truncateContent(event.content)}
185 </div>
186 {/if}
187 </div>
188 {#if event.kind !== 5 && (userRole === "admin" || userRole === "owner" || (userRole === "write" && event.pubkey && event.pubkey === userPubkey))}
189 <button
190 class="delete-btn"
191 on:click|stopPropagation={() =>
192 deleteEvent(event.id)}
193 >
194 🗑️
195 </button>
196 {/if}
197 </div>
198 {#if expandedEvents.has(event.id)}
199 <div class="events-view-details">
200 <div class="json-container">
201 <pre class="event-json">{JSON.stringify(
202 event,
203 null,
204 2,
205 )}</pre>
206 <button
207 class="copy-json-btn"
208 on:click|stopPropagation={(e) =>
209 copyEventToClipboard(event, e)}
210 title="Copy minified JSON to clipboard"
211 >
212 📋
213 </button>
214 </div>
215 </div>
216 {/if}
217 </div>
218 {/each}
219 {:else if !isLoadingEvents}
220 <div class="no-events">
221 <p>No events found.</p>
222 </div>
223 {/if}
224
225 {#if isLoadingEvents}
226 <div class="loading-events">
227 <div class="spinner"></div>
228 <p>Loading events...</p>
229 </div>
230 {/if}
231 </div>
232 <div class="events-view-footer">
233 <!-- Filter Builder Slide-up Panel -->
234 <div class="filter-panel" class:open={showFilterBuilder}>
235 <FilterBuilder
236 {showJsonEditor}
237 on:apply={handleFilterApply}
238 on:clear={handleFilterClear}
239 on:toggleJson={toggleJsonEditor}
240 />
241 </div>
242 <div class="events-view-header">
243 <div class="events-view-left">
244 <button
245 class="filter-btn"
246 class:active={showFilterBuilder}
247 on:click={toggleFilterBuilder}
248 title="Filter events"
249 >
250 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
251 <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
252 </svg>
253 </button>
254 <div class="events-view-toggle">
255 <label class="toggle-container">
256 <input
257 type="checkbox"
258 bind:checked={showOnlyMyEvents}
259 on:change={() => handleToggleChange()}
260 />
261 <span class="toggle-slider"></span>
262 <span class="toggle-label">Only show my events</span>
263 </label>
264 </div>
265 </div>
266 <div class="events-view-buttons">
267 <button
268 class="refresh-btn"
269 on:click={() => {
270 const authors =
271 showOnlyMyEvents && userPubkey
272 ? [userPubkey]
273 : null;
274 loadAllEvents(false, authors);
275 }}
276 disabled={isLoadingEvents}
277 >
278 🔄 Load More
279 </button>
280 <button
281 class="reload-btn"
282 on:click={() => {
283 const authors =
284 showOnlyMyEvents && userPubkey
285 ? [userPubkey]
286 : null;
287 loadAllEvents(true, authors);
288 }}
289 disabled={isLoadingEvents}
290 >
291 {#if isLoadingEvents}
292 <div class="spinner"></div>
293 {:else}
294 🔄
295 {/if}
296 </button>
297 </div>
298 </div>
299 </div>
300 </div>
301
302 <style>
303 .events-view-container {
304 width: 100%;
305 height: 100%;
306 display: flex;
307 flex-direction: column;
308 box-sizing: border-box;
309 }
310
311 .events-view-content {
312 flex: 1;
313 overflow-y: auto;
314 padding: 0;
315 }
316
317 /* Custom scrollbar styling */
318 .events-view-content::-webkit-scrollbar {
319 width: 16px;
320 background: var(--bg-color);
321 }
322
323 .events-view-content::-webkit-scrollbar-track {
324 background: var(--bg-color);
325 }
326
327 .events-view-content::-webkit-scrollbar-thumb {
328 background: var(--text-color);
329 border-radius: 9999px;
330 border: 4px solid var(--bg-color);
331 }
332
333 .events-view-content::-webkit-scrollbar-thumb:hover {
334 background: var(--text-color);
335 filter: brightness(1.2);
336 }
337
338 .events-view-content::-webkit-scrollbar-button {
339 background: var(--text-color);
340 height: 8px;
341 border: 4px solid var(--bg-color);
342 border-radius: 9999px;
343 background-clip: padding-box;
344 }
345
346 .events-view-item {
347 border: 0;
348 margin: 0;
349 transition: all 0.2s ease;
350 }
351
352 .events-view-item:hover {
353 padding: 0;
354 }
355
356 .events-view-row {
357 display: flex;
358 align-items: center;
359 padding: 0.5em;
360 cursor: pointer;
361 gap: 1em;
362 }
363
364 .events-view-avatar {
365 flex-shrink: 0;
366 }
367
368 .avatar-placeholder {
369 width: 40px;
370 height: 40px;
371 border-radius: 50%;
372 display: flex;
373 align-items: center;
374 justify-content: center;
375 font-size: 1.2em;
376 border: 0;
377 }
378
379 .events-view-info {
380 flex-shrink: 0;
381 min-width: 120px;
382 }
383
384 .events-view-author {
385 font-weight: 600;
386 color: var(--text-color);
387 font-size: 0.9em;
388 font-family: monospace;
389 }
390
391 .events-view-kind {
392 display: flex;
393 align-items: center;
394 gap: 0.5em;
395 margin-top: 0.25em;
396 }
397
398 .kind-number {
399 background: var(--card-bg);
400 color: var(--text-color);
401 padding: 0.1em 0.4em;
402 border: 1px solid var(--border-color);
403 font-size: 0.7em;
404 font-weight: 600;
405 font-family: monospace;
406 }
407
408 .kind-number.delete-event {
409 background: var(--danger);
410 }
411
412 .kind-name {
413 font-size: 0.8em;
414 color: var(--text-color);
415 opacity: 0.8;
416 }
417
418 .event-timestamp {
419 font-size: 0.8em;
420 color: var(--text-color);
421 opacity: 0.6;
422 margin-bottom: 0.5em;
423 }
424
425 .delete-event-info {
426 background: var(--danger-bg);
427 padding: 0.5em;
428 border-radius: 4px;
429 border: 1px solid var(--danger);
430 }
431
432 .delete-event-label {
433 font-weight: 600;
434 color: var(--danger);
435 display: block;
436 margin-bottom: 0.25em;
437 }
438
439 .delete-targets {
440 display: flex;
441 flex-wrap: wrap;
442 gap: 0.25em;
443 }
444
445 .delete-target {
446 background: var(--danger);
447 color: #ffffff;
448 padding: 0.1em 0.3em;
449 border-radius: 0.2rem;
450 font-size: 0.7em;
451 font-family: monospace;
452 }
453
454 .event-content-single-line {
455 color: var(--text-color);
456 line-height: 1.4;
457 word-wrap: break-word;
458 }
459
460 .delete-btn {
461 background: var(--danger);
462 color: var(--text-color);
463 border: none;
464 padding: 0.5em;
465 border-radius: 4px;
466 cursor: pointer;
467 font-size: 0.9em;
468 flex-shrink: 0;
469 transition: background-color 0.2s;
470 }
471
472 .delete-btn:hover {
473 background: var(--danger);
474 filter: brightness(0.9);
475 }
476
477 .events-view-details {
478 padding: 0;
479 background: var(--bg-color);
480 }
481
482 .json-container {
483 position: relative;
484 }
485
486 .event-json {
487 background: var(--code-bg);
488 padding: 1em;
489 border: 0;
490 font-size: 0.8em;
491 line-height: 1.4;
492 overflow-x: auto;
493 margin: 0;
494 color: var(--code-text);
495 }
496
497 .copy-json-btn {
498 position: absolute;
499 top: 1em;
500 right: 1em;
501 background: var(--primary);
502 color: var(--text-color);
503 border: none;
504 padding: 1em;
505 cursor: pointer;
506 font-size: 0.8em;
507 opacity: 0.8;
508 transition: opacity 0.2s;
509 }
510
511 .copy-json-btn:hover {
512 opacity: 1;
513 }
514
515 .no-events {
516 text-align: center;
517 padding: 2em;
518 color: var(--text-color);
519 opacity: 0.7;
520 }
521
522 .loading-events {
523 text-align: center;
524 padding: 2em;
525 color: var(--text-color);
526 }
527
528 .spinner {
529 width: 20px;
530 height: 20px;
531 border: 0;
532 border-radius: 50%;
533 animation: spin 1s linear infinite;
534 margin: 0 auto 1em;
535 }
536
537 @keyframes spin {
538 0% {
539 transform: rotate(0deg);
540 }
541 100% {
542 transform: rotate(360deg);
543 }
544 }
545
546
547 .events-view-footer {
548 position: relative;
549 flex-shrink: 0;
550 }
551
552 .events-view-header {
553 display: flex;
554 justify-content: space-between;
555 align-items: center;
556 padding: 0.5em;
557 border: 0;
558 background: var(--header-bg);
559 }
560
561 .events-view-toggle {
562 display: flex;
563 align-items: center;
564 }
565
566 .toggle-container {
567 display: flex;
568 align-items: center;
569 gap: 0.5em;
570 cursor: pointer;
571 }
572
573 .toggle-container input[type="checkbox"] {
574 display: none;
575 }
576
577 .toggle-slider {
578 width: 40px;
579 height: 20px;
580 background: var(--border-color);
581 border-radius: 10px;
582 position: relative;
583 transition: background 0.2s;
584 }
585
586 .toggle-slider::before {
587 content: "";
588 position: absolute;
589 width: 16px;
590 height: 16px;
591 background: var(--text-color);
592 border-radius: 50%;
593 top: 2px;
594 left: 2px;
595 transition: transform 0.2s;
596 }
597
598 .toggle-container input:checked + .toggle-slider {
599 background: var(--primary);
600 }
601
602 .toggle-container input:checked + .toggle-slider::before {
603 transform: translateX(20px);
604 }
605
606 .toggle-label {
607 font-size: 0.9em;
608 color: var(--text-color);
609 }
610
611 .events-view-buttons {
612 display: flex;
613 gap: 0.5em;
614 }
615
616 .refresh-btn,
617 .reload-btn {
618 background: var(--primary);
619 color: var(--text-color);
620 border: none;
621 padding: 0.4em 1em;
622 border-radius: 4px;
623 cursor: pointer;
624 font-size: 0.9em;
625 transition: background-color 0.2s;
626 display: flex;
627 align-items: center;
628 justify-content: center;
629 gap: 0.25em;
630 box-sizing: border-box;
631 line-height: 1;
632 }
633
634 .reload-btn {
635 width: 2.5em;
636 padding: 0.4em;
637 }
638
639 .refresh-btn:hover:not(:disabled),
640 .reload-btn:hover:not(:disabled) {
641 background: var(--accent-hover-color);
642 }
643
644 .refresh-btn:disabled,
645 .reload-btn:disabled {
646 background: var(--secondary);
647 cursor: not-allowed;
648 padding: 0.4em 1em;
649 }
650
651 .reload-btn:disabled {
652 padding: 0.4em;
653 }
654
655 .reload-btn .spinner {
656 width: 0.8em;
657 height: 0.8em;
658 border: 1.5px solid var(--text-color);
659 border-top-color: transparent;
660 border-radius: 50%;
661 animation: spin 1s linear infinite;
662 margin: 0;
663 box-sizing: border-box;
664 }
665
666 .events-view-left {
667 display: flex;
668 align-items: center;
669 gap: 0.75em;
670 }
671
672 .filter-btn {
673 background: var(--primary);
674 color: var(--text-color);
675 border: none;
676 padding: 0.4em;
677 border-radius: 4px;
678 cursor: pointer;
679 display: flex;
680 align-items: center;
681 justify-content: center;
682 transition: background-color 0.2s;
683 width: 2.2em;
684 height: 2.2em;
685 box-sizing: border-box;
686 }
687
688 .filter-btn:hover {
689 background: var(--accent-hover-color);
690 }
691
692 .filter-btn.active {
693 background: var(--accent-hover-color);
694 }
695
696 .filter-btn svg {
697 width: 1em;
698 height: 1em;
699 }
700
701 .filter-panel {
702 position: absolute;
703 bottom: 100%;
704 left: 0;
705 right: 0;
706 background: var(--bg-color);
707 border-top: 1px solid var(--border-color);
708 max-height: 0;
709 overflow: hidden;
710 transition: max-height 0.3s ease-out;
711 z-index: 100;
712 /* Account for scrollbar width in events-view-content */
713 padding-right: 16px;
714 box-sizing: border-box;
715 /* Flex column-reverse makes content anchor to top and grow downward */
716 display: flex;
717 flex-direction: column-reverse;
718 }
719
720 .filter-panel.open {
721 max-height: 60vh;
722 overflow-y: auto;
723 }
724
725 /* Custom scrollbar for filter panel */
726 .filter-panel::-webkit-scrollbar {
727 width: 16px;
728 background: var(--bg-color);
729 }
730
731 .filter-panel::-webkit-scrollbar-track {
732 background: var(--bg-color);
733 }
734
735 .filter-panel::-webkit-scrollbar-thumb {
736 background: var(--text-color);
737 border-radius: 9999px;
738 border: 4px solid var(--bg-color);
739 }
740
741 .filter-panel::-webkit-scrollbar-thumb:hover {
742 background: var(--text-color);
743 filter: brightness(1.2);
744 }
745
746 .filter-panel::-webkit-scrollbar-button {
747 background: var(--text-color);
748 height: 8px;
749 border: 4px solid var(--bg-color);
750 border-radius: 9999px;
751 background-clip: padding-box;
752 }
753 </style>
754