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