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