CurationView.svelte raw

   1  <script>
   2      import { onMount } from "svelte";
   3      import { curationKindCategories, parseCustomKinds, formatKindsCompact } from "./kindCategories.js";
   4      import { getApiBase, getWsUrl } from "./config.js";
   5  
   6      // Props
   7      export let userSigner;
   8      export let userPubkey;
   9  
  10      // State management
  11      let activeTab = "trusted";
  12      let isLoading = false;
  13      let message = "";
  14      let messageType = "info";
  15      let isConfigured = false;
  16  
  17      // User detail view state
  18      let selectedUser = null;
  19      let selectedUserType = null; // "trusted", "blacklisted", or "unclassified"
  20      let userEvents = [];
  21      let userEventsTotal = 0;
  22      let userEventsOffset = 0;
  23      let loadingEvents = false;
  24      let expandedEvents = {}; // Track which events are expanded
  25  
  26      // Configuration state
  27      let config = {
  28          daily_limit: 50,
  29          first_ban_hours: 1,
  30          second_ban_hours: 168,
  31          categories: [],
  32          custom_kinds: "",
  33          kind_ranges: []
  34      };
  35  
  36      // Trusted pubkeys
  37      let trustedPubkeys = [];
  38      let newTrustedPubkey = "";
  39      let newTrustedNote = "";
  40  
  41      // Blacklisted pubkeys
  42      let blacklistedPubkeys = [];
  43      let newBlacklistedPubkey = "";
  44      let newBlacklistedReason = "";
  45  
  46      // Unclassified users
  47      let unclassifiedUsers = [];
  48  
  49      // Spam events
  50      let spamEvents = [];
  51  
  52      // Blocked IPs
  53      let blockedIPs = [];
  54  
  55      // Check configuration on mount
  56      onMount(async () => {
  57          await checkConfiguration();
  58      });
  59  
  60      // Create NIP-98 authentication event
  61      async function createNIP98AuthEvent(method, url) {
  62          if (!userSigner) {
  63              throw new Error("No signer available. Please log in with a Nostr extension.");
  64          }
  65          if (!userPubkey) {
  66              throw new Error("No user pubkey available.");
  67          }
  68  
  69          const fullUrl = getApiBase() + url;
  70          const authEvent = {
  71              kind: 27235,
  72              created_at: Math.floor(Date.now() / 1000),
  73              tags: [
  74                  ["u", fullUrl],
  75                  ["method", method],
  76              ],
  77              content: "",
  78              pubkey: userPubkey,
  79          };
  80  
  81          const signedAuthEvent = await userSigner.signEvent(authEvent);
  82          return `Nostr ${btoa(JSON.stringify(signedAuthEvent))}`;
  83      }
  84  
  85      // Make NIP-86 API call
  86      async function callNIP86API(method, params = []) {
  87          try {
  88              isLoading = true;
  89              message = "";
  90  
  91              const request = { method, params };
  92              const authHeader = await createNIP98AuthEvent("POST", "/api/nip86");
  93  
  94              const response = await fetch("/api/nip86", {
  95                  method: "POST",
  96                  headers: {
  97                      "Content-Type": "application/nostr+json+rpc",
  98                      Authorization: authHeader,
  99                  },
 100                  body: JSON.stringify(request),
 101              });
 102  
 103              if (!response.ok) {
 104                  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
 105              }
 106  
 107              const result = await response.json();
 108              if (result.error) {
 109                  throw new Error(result.error);
 110              }
 111  
 112              return result.result;
 113          } catch (error) {
 114              console.error("NIP-86 API error:", error);
 115              message = error.message;
 116              messageType = "error";
 117              throw error;
 118          } finally {
 119              isLoading = false;
 120          }
 121      }
 122  
 123      // Check if curating mode is configured
 124      async function checkConfiguration() {
 125          try {
 126              const result = await callNIP86API("isconfigured");
 127              isConfigured = result === true;
 128  
 129              if (isConfigured) {
 130                  await loadConfig();
 131                  await loadAllData();
 132              }
 133          } catch (error) {
 134              console.error("Failed to check configuration:", error);
 135              isConfigured = false;
 136          }
 137      }
 138  
 139      // Load current configuration
 140      async function loadConfig() {
 141          try {
 142              const result = await callNIP86API("getcuratingconfig");
 143              if (result) {
 144                  config = {
 145                      daily_limit: result.daily_limit || 50,
 146                      first_ban_hours: result.first_ban_hours || 1,
 147                      second_ban_hours: result.second_ban_hours || 168,
 148                      categories: result.categories || [],
 149                      custom_kinds: result.custom_kinds ? result.custom_kinds.join(", ") : "",
 150                      kind_ranges: result.kind_ranges || []
 151                  };
 152              }
 153          } catch (error) {
 154              console.error("Failed to load config:", error);
 155          }
 156      }
 157  
 158      // Load all data
 159      async function loadAllData() {
 160          await Promise.all([
 161              loadTrustedPubkeys(),
 162              loadBlacklistedPubkeys(),
 163              loadUnclassifiedUsers(),
 164              loadSpamEvents(),
 165              loadBlockedIPs(),
 166          ]);
 167      }
 168  
 169      // Load trusted pubkeys
 170      async function loadTrustedPubkeys() {
 171          try {
 172              trustedPubkeys = await callNIP86API("listtrustedpubkeys");
 173          } catch (error) {
 174              console.error("Failed to load trusted pubkeys:", error);
 175              trustedPubkeys = [];
 176          }
 177      }
 178  
 179      // Load blacklisted pubkeys
 180      async function loadBlacklistedPubkeys() {
 181          try {
 182              blacklistedPubkeys = await callNIP86API("listblacklistedpubkeys");
 183          } catch (error) {
 184              console.error("Failed to load blacklisted pubkeys:", error);
 185              blacklistedPubkeys = [];
 186          }
 187      }
 188  
 189      // Load unclassified users
 190      async function loadUnclassifiedUsers() {
 191          try {
 192              unclassifiedUsers = await callNIP86API("listunclassifiedusers");
 193          } catch (error) {
 194              console.error("Failed to load unclassified users:", error);
 195              unclassifiedUsers = [];
 196          }
 197      }
 198  
 199      // Scan database for all pubkeys
 200      async function scanDatabase() {
 201          try {
 202              const result = await callNIP86API("scanpubkeys");
 203              showMessage(`Database scanned: ${result.total_pubkeys} pubkeys, ${result.total_events} events (${result.skipped} skipped)`, "success");
 204              // Refresh the unclassified users list
 205              await loadUnclassifiedUsers();
 206          } catch (error) {
 207              console.error("Failed to scan database:", error);
 208              showMessage("Failed to scan database: " + error.message, "error");
 209          }
 210      }
 211  
 212      // Load spam events
 213      async function loadSpamEvents() {
 214          try {
 215              spamEvents = await callNIP86API("listspamevents");
 216          } catch (error) {
 217              console.error("Failed to load spam events:", error);
 218              spamEvents = [];
 219          }
 220      }
 221  
 222      // Load blocked IPs
 223      async function loadBlockedIPs() {
 224          try {
 225              blockedIPs = await callNIP86API("listblockedips");
 226          } catch (error) {
 227              console.error("Failed to load blocked IPs:", error);
 228              blockedIPs = [];
 229          }
 230      }
 231  
 232      // Trust a pubkey
 233      async function trustPubkey(pubkey = null, note = "") {
 234          const pk = pubkey || newTrustedPubkey;
 235          const n = pubkey ? note : newTrustedNote;
 236  
 237          if (!pk) return;
 238  
 239          try {
 240              await callNIP86API("trustpubkey", [pk, n]);
 241              message = "Pubkey trusted successfully";
 242              messageType = "success";
 243              newTrustedPubkey = "";
 244              newTrustedNote = "";
 245              await loadTrustedPubkeys();
 246              await loadUnclassifiedUsers();
 247          } catch (error) {
 248              console.error("Failed to trust pubkey:", error);
 249          }
 250      }
 251  
 252      // Untrust a pubkey
 253      async function untrustPubkey(pubkey) {
 254          try {
 255              await callNIP86API("untrustpubkey", [pubkey]);
 256              message = "Pubkey untrusted";
 257              messageType = "success";
 258              await loadTrustedPubkeys();
 259          } catch (error) {
 260              console.error("Failed to untrust pubkey:", error);
 261          }
 262      }
 263  
 264      // Blacklist a pubkey
 265      async function blacklistPubkey(pubkey = null, reason = "") {
 266          const pk = pubkey || newBlacklistedPubkey;
 267          const r = pubkey ? reason : newBlacklistedReason;
 268  
 269          if (!pk) return;
 270  
 271          try {
 272              await callNIP86API("blacklistpubkey", [pk, r]);
 273              message = "Pubkey blacklisted";
 274              messageType = "success";
 275              newBlacklistedPubkey = "";
 276              newBlacklistedReason = "";
 277              await loadBlacklistedPubkeys();
 278              await loadUnclassifiedUsers();
 279          } catch (error) {
 280              console.error("Failed to blacklist pubkey:", error);
 281          }
 282      }
 283  
 284      // Remove from blacklist
 285      async function unblacklistPubkey(pubkey) {
 286          try {
 287              await callNIP86API("unblacklistpubkey", [pubkey]);
 288              message = "Pubkey removed from blacklist";
 289              messageType = "success";
 290              await loadBlacklistedPubkeys();
 291          } catch (error) {
 292              console.error("Failed to remove from blacklist:", error);
 293          }
 294      }
 295  
 296      // Mark event as spam
 297      async function markSpam(eventId, reason) {
 298          try {
 299              await callNIP86API("markspam", [eventId, reason]);
 300              message = "Event marked as spam";
 301              messageType = "success";
 302              await loadSpamEvents();
 303          } catch (error) {
 304              console.error("Failed to mark spam:", error);
 305          }
 306      }
 307  
 308      // Unmark spam
 309      async function unmarkSpam(eventId) {
 310          try {
 311              await callNIP86API("unmarkspam", [eventId]);
 312              message = "Spam mark removed";
 313              messageType = "success";
 314              await loadSpamEvents();
 315          } catch (error) {
 316              console.error("Failed to unmark spam:", error);
 317          }
 318      }
 319  
 320      // Delete event
 321      async function deleteEvent(eventId) {
 322          if (!confirm("Permanently delete this event?")) return;
 323  
 324          try {
 325              await callNIP86API("deleteevent", [eventId]);
 326              message = "Event deleted";
 327              messageType = "success";
 328              await loadSpamEvents();
 329          } catch (error) {
 330              console.error("Failed to delete event:", error);
 331          }
 332      }
 333  
 334      // Unblock IP
 335      async function unblockIP(ip) {
 336          try {
 337              await callNIP86API("unblockip", [ip]);
 338              message = "IP unblocked";
 339              messageType = "success";
 340              await loadBlockedIPs();
 341          } catch (error) {
 342              console.error("Failed to unblock IP:", error);
 343          }
 344      }
 345  
 346      // Toggle category selection
 347      function toggleCategory(categoryId) {
 348          if (config.categories.includes(categoryId)) {
 349              config.categories = config.categories.filter(c => c !== categoryId);
 350          } else {
 351              config.categories = [...config.categories, categoryId];
 352          }
 353      }
 354  
 355      // Publish configuration event
 356      async function publishConfiguration() {
 357          if (!userSigner || !userPubkey) {
 358              message = "Please log in with a Nostr extension to publish configuration";
 359              messageType = "error";
 360              return;
 361          }
 362  
 363          if (config.categories.length === 0 && !config.custom_kinds.trim()) {
 364              message = "Please select at least one kind category or enter custom kinds";
 365              messageType = "error";
 366              return;
 367          }
 368  
 369          try {
 370              isLoading = true;
 371              message = "";
 372  
 373              // Build tags
 374              const tags = [
 375                  ["d", "curating-config"],
 376                  ["daily_limit", String(config.daily_limit)],
 377                  ["first_ban_hours", String(config.first_ban_hours)],
 378                  ["second_ban_hours", String(config.second_ban_hours)],
 379              ];
 380  
 381              // Add category tags
 382              for (const cat of config.categories) {
 383                  tags.push(["kind_category", cat]);
 384              }
 385  
 386              // Parse and add custom kinds
 387              const customKinds = parseCustomKinds(config.custom_kinds);
 388              for (const kind of customKinds) {
 389                  tags.push(["kind", String(kind)]);
 390              }
 391  
 392              // Create the configuration event
 393              const configEvent = {
 394                  kind: 30078,
 395                  created_at: Math.floor(Date.now() / 1000),
 396                  tags: tags,
 397                  content: "Curating relay configuration",
 398                  pubkey: userPubkey,
 399              };
 400  
 401              // Sign the event
 402              const signedEvent = await userSigner.signEvent(configEvent);
 403  
 404              // Submit to relay via WebSocket
 405              const ws = new WebSocket(getWsUrl());
 406  
 407              await new Promise((resolve, reject) => {
 408                  ws.onopen = () => {
 409                      ws.send(JSON.stringify(["EVENT", signedEvent]));
 410                  };
 411                  ws.onmessage = (e) => {
 412                      const msg = JSON.parse(e.data);
 413                      if (msg[0] === "OK") {
 414                          if (msg[2] === true) {
 415                              resolve();
 416                          } else {
 417                              reject(new Error(msg[3] || "Event rejected"));
 418                          }
 419                      }
 420                  };
 421                  ws.onerror = (e) => reject(new Error("WebSocket error"));
 422                  setTimeout(() => reject(new Error("Timeout")), 10000);
 423              });
 424  
 425              ws.close();
 426  
 427              message = "Configuration published successfully";
 428              messageType = "success";
 429              isConfigured = true;
 430              await loadAllData();
 431          } catch (error) {
 432              console.error("Failed to publish configuration:", error);
 433              message = `Failed to publish: ${error.message}`;
 434              messageType = "error";
 435          } finally {
 436              isLoading = false;
 437          }
 438      }
 439  
 440      // Update configuration (re-publish)
 441      async function updateConfiguration() {
 442          await publishConfiguration();
 443      }
 444  
 445      // Format pubkey for display
 446      function formatPubkey(pubkey) {
 447          if (!pubkey || pubkey.length < 16) return pubkey;
 448          return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
 449      }
 450  
 451      // Format date
 452      function formatDate(timestamp) {
 453          if (!timestamp) return "";
 454          return new Date(timestamp).toLocaleString();
 455      }
 456  
 457      // Show message helper
 458      function showMessage(msg, type = "info") {
 459          message = msg;
 460          messageType = type;
 461      }
 462  
 463      // Open user detail view
 464      async function openUserDetail(pubkey, type) {
 465          console.log("openUserDetail called:", pubkey, type);
 466          selectedUser = pubkey;
 467          selectedUserType = type;
 468          userEvents = [];
 469          userEventsTotal = 0;
 470          userEventsOffset = 0;
 471          expandedEvents = {};
 472          console.log("selectedUser set to:", selectedUser);
 473          await loadUserEvents();
 474      }
 475  
 476      // Close user detail view
 477      function closeUserDetail() {
 478          selectedUser = null;
 479          selectedUserType = null;
 480          userEvents = [];
 481          userEventsTotal = 0;
 482          userEventsOffset = 0;
 483          expandedEvents = {};
 484      }
 485  
 486      // Load events for selected user
 487      async function loadUserEvents() {
 488          console.log("loadUserEvents called, selectedUser:", selectedUser, "loadingEvents:", loadingEvents);
 489          if (!selectedUser || loadingEvents) return;
 490  
 491          try {
 492              loadingEvents = true;
 493              console.log("Calling geteventsforpubkey API...");
 494              const result = await callNIP86API("geteventsforpubkey", [selectedUser, 100, userEventsOffset]);
 495              console.log("API result:", result);
 496              if (result) {
 497                  if (userEventsOffset === 0) {
 498                      userEvents = result.events || [];
 499                  } else {
 500                      userEvents = [...userEvents, ...(result.events || [])];
 501                  }
 502                  userEventsTotal = result.total || 0;
 503              }
 504          } catch (error) {
 505              console.error("Failed to load user events:", error);
 506              showMessage("Failed to load events: " + error.message, "error");
 507          } finally {
 508              loadingEvents = false;
 509          }
 510      }
 511  
 512      // Load more events
 513      async function loadMoreEvents() {
 514          userEventsOffset = userEvents.length;
 515          await loadUserEvents();
 516      }
 517  
 518      // Toggle event expansion
 519      function toggleEventExpansion(eventId) {
 520          expandedEvents = {
 521              ...expandedEvents,
 522              [eventId]: !expandedEvents[eventId]
 523          };
 524      }
 525  
 526      // Truncate content to 6 lines (approximately 300 chars per line)
 527      function truncateContent(content, maxLines = 6) {
 528          if (!content) return "";
 529          const lines = content.split('\n');
 530          if (lines.length <= maxLines && content.length <= maxLines * 100) {
 531              return content;
 532          }
 533          // Truncate by lines or characters, whichever is smaller
 534          let truncated = lines.slice(0, maxLines).join('\n');
 535          if (truncated.length > maxLines * 100) {
 536              truncated = truncated.substring(0, maxLines * 100);
 537          }
 538          return truncated;
 539      }
 540  
 541      // Check if content is truncated
 542      function isContentTruncated(content, maxLines = 6) {
 543          if (!content) return false;
 544          const lines = content.split('\n');
 545          return lines.length > maxLines || content.length > maxLines * 100;
 546      }
 547  
 548      // Trust user from detail view and refresh
 549      async function trustUserFromDetail() {
 550          await trustPubkey(selectedUser, "");
 551          // Refresh list and go back
 552          await loadAllData();
 553          closeUserDetail();
 554      }
 555  
 556      // Blacklist user from detail view and refresh
 557      async function blacklistUserFromDetail() {
 558          await blacklistPubkey(selectedUser, "");
 559          // Refresh list and go back
 560          await loadAllData();
 561          closeUserDetail();
 562      }
 563  
 564      // Untrust user from detail view and refresh
 565      async function untrustUserFromDetail() {
 566          await untrustPubkey(selectedUser);
 567          await loadAllData();
 568          closeUserDetail();
 569      }
 570  
 571      // Unblacklist user from detail view and refresh
 572      async function unblacklistUserFromDetail() {
 573          await unblacklistPubkey(selectedUser);
 574          await loadAllData();
 575          closeUserDetail();
 576      }
 577  
 578      // Delete all events for a blacklisted user
 579      async function deleteAllEventsForUser() {
 580          if (!confirm(`Delete ALL ${userEventsTotal} events from this user? This cannot be undone.`)) {
 581              return;
 582          }
 583  
 584          try {
 585              isLoading = true;
 586              const result = await callNIP86API("deleteeventsforpubkey", [selectedUser]);
 587              showMessage(`Deleted ${result.deleted} events`, "success");
 588              // Refresh the events list
 589              userEvents = [];
 590              userEventsTotal = 0;
 591              userEventsOffset = 0;
 592              await loadUserEvents();
 593          } catch (error) {
 594              console.error("Failed to delete events:", error);
 595              showMessage("Failed to delete events: " + error.message, "error");
 596          } finally {
 597              isLoading = false;
 598          }
 599      }
 600  
 601      // Get kind name
 602      function getKindName(kind) {
 603          const kindNames = {
 604              0: "Metadata",
 605              1: "Text Note",
 606              3: "Follow List",
 607              4: "Encrypted DM",
 608              6: "Repost",
 609              7: "Reaction",
 610              14: "Chat Message",
 611              16: "Order Message",
 612              17: "Payment Receipt",
 613              1063: "File Metadata",
 614              10002: "Relay List",
 615              30017: "Stall",
 616              30018: "Product (NIP-15)",
 617              30023: "Long-form",
 618              30078: "App Data",
 619              30402: "Product (NIP-99)",
 620              30405: "Collection",
 621              30406: "Shipping",
 622              31555: "Review",
 623          };
 624          return kindNames[kind] || `Kind ${kind}`;
 625      }
 626  </script>
 627  
 628  <div class="curation-view">
 629      <h2>Curation Mode</h2>
 630  
 631      {#if message}
 632          <div class="message {messageType}">{message}</div>
 633      {/if}
 634  
 635      {#if !isConfigured}
 636          <!-- Setup Mode -->
 637          <div class="setup-section">
 638              <div class="setup-header">
 639                  <h3>Initial Configuration</h3>
 640                  <p>Configure curating mode before the relay will accept events. Select which event kinds to allow and set rate limiting parameters.</p>
 641              </div>
 642  
 643              <div class="config-section">
 644                  <h4>Allowed Event Kinds</h4>
 645                  <p class="help-text">Select categories of events to allow. At least one must be selected.</p>
 646  
 647                  <div class="category-grid">
 648                      {#each curationKindCategories as category}
 649                          <label class="category-item" class:selected={config.categories.includes(category.id)}>
 650                              <input
 651                                  type="checkbox"
 652                                  checked={config.categories.includes(category.id)}
 653                                  on:change={() => toggleCategory(category.id)}
 654                              />
 655                              <div class="category-info">
 656                                  <span class="category-name">{category.name}</span>
 657                                  <span class="category-desc">{category.description}</span>
 658                                  <span class="category-kinds">Kinds: {category.kinds.join(", ")}</span>
 659                              </div>
 660                          </label>
 661                      {/each}
 662                  </div>
 663  
 664                  <div class="custom-kinds">
 665                      <label for="custom-kinds">Custom Kinds (comma-separated, ranges allowed e.g., "100, 200-300")</label>
 666                      <input
 667                          id="custom-kinds"
 668                          type="text"
 669                          bind:value={config.custom_kinds}
 670                          placeholder="e.g., 100, 200-250, 500"
 671                      />
 672                  </div>
 673              </div>
 674  
 675              <div class="config-section">
 676                  <h4>Rate Limiting</h4>
 677  
 678                  <div class="form-row">
 679                      <div class="form-group">
 680                          <label for="daily-limit">Daily Event Limit (unclassified users)</label>
 681                          <input
 682                              id="daily-limit"
 683                              type="number"
 684                              min="1"
 685                              bind:value={config.daily_limit}
 686                          />
 687                      </div>
 688                  </div>
 689  
 690                  <div class="form-row">
 691                      <div class="form-group">
 692                          <label for="first-ban">First IP Ban Duration (hours)</label>
 693                          <input
 694                              id="first-ban"
 695                              type="number"
 696                              min="1"
 697                              bind:value={config.first_ban_hours}
 698                          />
 699                      </div>
 700                      <div class="form-group">
 701                          <label for="second-ban">Second+ IP Ban Duration (hours)</label>
 702                          <input
 703                              id="second-ban"
 704                              type="number"
 705                              min="1"
 706                              bind:value={config.second_ban_hours}
 707                          />
 708                      </div>
 709                  </div>
 710              </div>
 711  
 712              <div class="publish-section">
 713                  <button
 714                      class="publish-btn"
 715                      on:click={publishConfiguration}
 716                      disabled={isLoading}
 717                  >
 718                      {#if isLoading}
 719                          Publishing...
 720                      {:else}
 721                          Publish Configuration
 722                      {/if}
 723                  </button>
 724                  <p class="publish-note">This will publish a kind 30078 event to activate curating mode.</p>
 725              </div>
 726          </div>
 727      {:else}
 728          <!-- User Detail View -->
 729          {#if selectedUser}
 730              <div class="user-detail-view">
 731                  <div class="detail-header">
 732                      <div class="detail-header-left">
 733                          <button class="back-btn" on:click={closeUserDetail}>
 734                              &larr; Back
 735                          </button>
 736                          <h3>User Events</h3>
 737                          <span class="detail-pubkey" title={selectedUser}>{formatPubkey(selectedUser)}</span>
 738                          <span class="detail-count">{userEventsTotal} events</span>
 739                      </div>
 740                      <div class="detail-header-right">
 741                          {#if selectedUserType === "trusted"}
 742                              <button class="btn-danger" on:click={untrustUserFromDetail}>Remove Trust</button>
 743                              <button class="btn-danger" on:click={blacklistUserFromDetail}>Blacklist</button>
 744                          {:else if selectedUserType === "blacklisted"}
 745                              <button class="btn-delete-all" on:click={deleteAllEventsForUser} disabled={isLoading || userEventsTotal === 0}>
 746                                  Delete All Events
 747                              </button>
 748                              <button class="btn-success" on:click={unblacklistUserFromDetail}>Remove from Blacklist</button>
 749                              <button class="btn-success" on:click={trustUserFromDetail}>Trust</button>
 750                          {:else}
 751                              <button class="btn-success" on:click={trustUserFromDetail}>Trust</button>
 752                              <button class="btn-danger" on:click={blacklistUserFromDetail}>Blacklist</button>
 753                          {/if}
 754                      </div>
 755                  </div>
 756  
 757                  <div class="events-list">
 758                      {#if loadingEvents && userEvents.length === 0}
 759                          <div class="loading">Loading events...</div>
 760                      {:else if userEvents.length === 0}
 761                          <div class="empty">No events found for this user.</div>
 762                      {:else}
 763                          {#each userEvents as event}
 764                              <div class="event-item">
 765                                  <div class="event-header">
 766                                      <span class="event-kind">{getKindName(event.kind)}</span>
 767                                      <span class="event-id" title={event.id}>{formatPubkey(event.id)}</span>
 768                                      <span class="event-time">{formatDate(event.created_at * 1000)}</span>
 769                                  </div>
 770                                  <div class="event-content" class:expanded={expandedEvents[event.id]}>
 771                                      {#if expandedEvents[event.id] || !isContentTruncated(event.content)}
 772                                          <pre>{event.content || "(empty)"}</pre>
 773                                      {:else}
 774                                          <pre>{truncateContent(event.content)}...</pre>
 775                                      {/if}
 776                                  </div>
 777                                  {#if isContentTruncated(event.content)}
 778                                      <button class="expand-btn" on:click={() => toggleEventExpansion(event.id)}>
 779                                          {expandedEvents[event.id] ? "Show less" : "Show more"}
 780                                      </button>
 781                                  {/if}
 782                              </div>
 783                          {/each}
 784  
 785                          {#if userEvents.length < userEventsTotal}
 786                              <div class="load-more">
 787                                  <button on:click={loadMoreEvents} disabled={loadingEvents}>
 788                                      {loadingEvents ? "Loading..." : `Load more (${userEvents.length} of ${userEventsTotal})`}
 789                                  </button>
 790                              </div>
 791                          {/if}
 792                      {/if}
 793                  </div>
 794              </div>
 795          {:else}
 796              <!-- Active Mode -->
 797              <div class="tabs">
 798                  <button class="tab" class:active={activeTab === "trusted"} on:click={() => activeTab = "trusted"}>
 799                      Trusted ({trustedPubkeys.length})
 800                  </button>
 801                  <button class="tab" class:active={activeTab === "blacklist"} on:click={() => activeTab = "blacklist"}>
 802                      Blacklist ({blacklistedPubkeys.length})
 803                  </button>
 804                  <button class="tab" class:active={activeTab === "unclassified"} on:click={() => activeTab = "unclassified"}>
 805                      Unclassified ({unclassifiedUsers.length})
 806                  </button>
 807                  <button class="tab" class:active={activeTab === "spam"} on:click={() => activeTab = "spam"}>
 808                      Spam ({spamEvents.length})
 809                  </button>
 810                  <button class="tab" class:active={activeTab === "ips"} on:click={() => activeTab = "ips"}>
 811                      Blocked IPs ({blockedIPs.length})
 812                  </button>
 813                  <button class="tab" class:active={activeTab === "settings"} on:click={() => activeTab = "settings"}>
 814                      Settings
 815                  </button>
 816              </div>
 817  
 818              <div class="tab-content">
 819              {#if activeTab === "trusted"}
 820                  <div class="section">
 821                      <h3>Trusted Publishers</h3>
 822                      <p class="help-text">Trusted users can publish unlimited events without rate limiting.</p>
 823  
 824                      <div class="add-form">
 825                          <input
 826                              type="text"
 827                              placeholder="Pubkey (64 hex chars)"
 828                              bind:value={newTrustedPubkey}
 829                          />
 830                          <input
 831                              type="text"
 832                              placeholder="Note (optional)"
 833                              bind:value={newTrustedNote}
 834                          />
 835                          <button on:click={() => trustPubkey()} disabled={isLoading}>
 836                              Trust
 837                          </button>
 838                      </div>
 839  
 840                      <div class="list">
 841                          {#if trustedPubkeys.length > 0}
 842                              {#each trustedPubkeys as item}
 843                                  <div class="list-item clickable" on:click={() => openUserDetail(item.pubkey, "trusted")}>
 844                                      <div class="item-main">
 845                                          <span class="pubkey" title={item.pubkey}>{formatPubkey(item.pubkey)}</span>
 846                                          {#if item.note}
 847                                              <span class="note">{item.note}</span>
 848                                          {/if}
 849                                      </div>
 850                                      <div class="item-actions">
 851                                          <button class="btn-danger" on:click|stopPropagation={() => untrustPubkey(item.pubkey)}>
 852                                              Remove
 853                                          </button>
 854                                      </div>
 855                                  </div>
 856                              {/each}
 857                          {:else}
 858                              <div class="empty">No trusted pubkeys yet.</div>
 859                          {/if}
 860                      </div>
 861                  </div>
 862              {/if}
 863  
 864              {#if activeTab === "blacklist"}
 865                  <div class="section">
 866                      <h3>Blacklisted Publishers</h3>
 867                      <p class="help-text">Blacklisted users cannot publish any events.</p>
 868  
 869                      <div class="add-form">
 870                          <input
 871                              type="text"
 872                              placeholder="Pubkey (64 hex chars)"
 873                              bind:value={newBlacklistedPubkey}
 874                          />
 875                          <input
 876                              type="text"
 877                              placeholder="Reason (optional)"
 878                              bind:value={newBlacklistedReason}
 879                          />
 880                          <button on:click={() => blacklistPubkey()} disabled={isLoading}>
 881                              Blacklist
 882                          </button>
 883                      </div>
 884  
 885                      <div class="list">
 886                          {#if blacklistedPubkeys.length > 0}
 887                              {#each blacklistedPubkeys as item}
 888                                  <div class="list-item clickable" on:click={() => openUserDetail(item.pubkey, "blacklisted")}>
 889                                      <div class="item-main">
 890                                          <span class="pubkey" title={item.pubkey}>{formatPubkey(item.pubkey)}</span>
 891                                          {#if item.reason}
 892                                              <span class="reason">{item.reason}</span>
 893                                          {/if}
 894                                      </div>
 895                                      <div class="item-actions">
 896                                          <button class="btn-success" on:click|stopPropagation={() => unblacklistPubkey(item.pubkey)}>
 897                                              Remove
 898                                          </button>
 899                                      </div>
 900                                  </div>
 901                              {/each}
 902                          {:else}
 903                              <div class="empty">No blacklisted pubkeys.</div>
 904                          {/if}
 905                      </div>
 906                  </div>
 907              {/if}
 908  
 909              {#if activeTab === "unclassified"}
 910                  <div class="section">
 911                      <h3>Unclassified Users</h3>
 912                      <p class="help-text">Users who have posted events but haven't been classified. Sorted by event count.</p>
 913  
 914                      <div class="button-row">
 915                          <button class="refresh-btn" on:click={loadUnclassifiedUsers} disabled={isLoading}>
 916                              Refresh
 917                          </button>
 918                          <button class="scan-btn" on:click={scanDatabase} disabled={isLoading}>
 919                              Scan Database
 920                          </button>
 921                      </div>
 922  
 923                      <div class="list">
 924                          {#if unclassifiedUsers.length > 0}
 925                              {#each unclassifiedUsers as user}
 926                                  <div class="list-item clickable" on:click={() => openUserDetail(user.pubkey, "unclassified")}>
 927                                      <div class="item-main">
 928                                          <span class="pubkey" title={user.pubkey}>{formatPubkey(user.pubkey)}</span>
 929                                          <span class="event-count">{user.event_count} events</span>
 930                                      </div>
 931                                      <div class="item-actions">
 932                                          <button class="btn-success" on:click|stopPropagation={() => trustPubkey(user.pubkey, "")}>
 933                                              Trust
 934                                          </button>
 935                                          <button class="btn-danger" on:click|stopPropagation={() => blacklistPubkey(user.pubkey, "")}>
 936                                              Blacklist
 937                                          </button>
 938                                      </div>
 939                                  </div>
 940                              {/each}
 941                          {:else}
 942                              <div class="empty">No unclassified users.</div>
 943                          {/if}
 944                      </div>
 945                  </div>
 946              {/if}
 947  
 948              {#if activeTab === "spam"}
 949                  <div class="section">
 950                      <h3>Spam Events</h3>
 951                      <p class="help-text">Events flagged as spam are hidden from query results but remain in the database.</p>
 952  
 953                      <button class="refresh-btn" on:click={loadSpamEvents} disabled={isLoading}>
 954                          Refresh
 955                      </button>
 956  
 957                      <div class="list">
 958                          {#if spamEvents.length > 0}
 959                              {#each spamEvents as event}
 960                                  <div class="list-item">
 961                                      <div class="item-main">
 962                                          <span class="event-id" title={event.event_id}>{formatPubkey(event.event_id)}</span>
 963                                          <span class="pubkey" title={event.pubkey}>by {formatPubkey(event.pubkey)}</span>
 964                                          {#if event.reason}
 965                                              <span class="reason">{event.reason}</span>
 966                                          {/if}
 967                                      </div>
 968                                      <div class="item-actions">
 969                                          <button class="btn-success" on:click={() => unmarkSpam(event.event_id)}>
 970                                              Unmark
 971                                          </button>
 972                                          <button class="btn-danger" on:click={() => deleteEvent(event.event_id)}>
 973                                              Delete
 974                                          </button>
 975                                      </div>
 976                                  </div>
 977                              {/each}
 978                          {:else}
 979                              <div class="empty">No spam events flagged.</div>
 980                          {/if}
 981                      </div>
 982                  </div>
 983              {/if}
 984  
 985              {#if activeTab === "ips"}
 986                  <div class="section">
 987                      <h3>Blocked IP Addresses</h3>
 988                      <p class="help-text">IP addresses blocked due to rate limit violations.</p>
 989  
 990                      <button class="refresh-btn" on:click={loadBlockedIPs} disabled={isLoading}>
 991                          Refresh
 992                      </button>
 993  
 994                      <div class="list">
 995                          {#if blockedIPs.length > 0}
 996                              {#each blockedIPs as ip}
 997                                  <div class="list-item">
 998                                      <div class="item-main">
 999                                          <span class="ip">{ip.ip}</span>
1000                                          {#if ip.reason}
1001                                              <span class="reason">{ip.reason}</span>
1002                                          {/if}
1003                                          {#if ip.expires_at}
1004                                              <span class="expires">Expires: {formatDate(ip.expires_at)}</span>
1005                                          {/if}
1006                                      </div>
1007                                      <div class="item-actions">
1008                                          <button class="btn-success" on:click={() => unblockIP(ip.ip)}>
1009                                              Unblock
1010                                          </button>
1011                                      </div>
1012                                  </div>
1013                              {/each}
1014                          {:else}
1015                              <div class="empty">No blocked IPs.</div>
1016                          {/if}
1017                      </div>
1018                  </div>
1019              {/if}
1020  
1021              {#if activeTab === "settings"}
1022                  <div class="section">
1023                      <h3>Curating Configuration</h3>
1024                      <p class="help-text">Update curating mode settings. Changes will publish a new configuration event.</p>
1025  
1026                      <div class="config-section">
1027                          <h4>Allowed Event Kinds</h4>
1028  
1029                          <div class="category-grid">
1030                              {#each curationKindCategories as category}
1031                                  <label class="category-item" class:selected={config.categories.includes(category.id)}>
1032                                      <input
1033                                          type="checkbox"
1034                                          checked={config.categories.includes(category.id)}
1035                                          on:change={() => toggleCategory(category.id)}
1036                                      />
1037                                      <div class="category-info">
1038                                          <span class="category-name">{category.name}</span>
1039                                          <span class="category-kinds">Kinds: {category.kinds.join(", ")}</span>
1040                                      </div>
1041                                  </label>
1042                              {/each}
1043                          </div>
1044  
1045                          <div class="custom-kinds">
1046                              <label for="custom-kinds-edit">Custom Kinds</label>
1047                              <input
1048                                  id="custom-kinds-edit"
1049                                  type="text"
1050                                  bind:value={config.custom_kinds}
1051                                  placeholder="e.g., 100, 200-250, 500"
1052                              />
1053                          </div>
1054                      </div>
1055  
1056                      <div class="config-section">
1057                          <h4>Rate Limiting</h4>
1058  
1059                          <div class="form-row">
1060                              <div class="form-group">
1061                                  <label for="daily-limit-edit">Daily Event Limit</label>
1062                                  <input
1063                                      id="daily-limit-edit"
1064                                      type="number"
1065                                      min="1"
1066                                      bind:value={config.daily_limit}
1067                                  />
1068                              </div>
1069                          </div>
1070  
1071                          <div class="form-row">
1072                              <div class="form-group">
1073                                  <label for="first-ban-edit">First Ban (hours)</label>
1074                                  <input
1075                                      id="first-ban-edit"
1076                                      type="number"
1077                                      min="1"
1078                                      bind:value={config.first_ban_hours}
1079                                  />
1080                              </div>
1081                              <div class="form-group">
1082                                  <label for="second-ban-edit">Second+ Ban (hours)</label>
1083                                  <input
1084                                      id="second-ban-edit"
1085                                      type="number"
1086                                      min="1"
1087                                      bind:value={config.second_ban_hours}
1088                                  />
1089                              </div>
1090                          </div>
1091                      </div>
1092  
1093                      <div class="publish-section">
1094                          <button
1095                              class="publish-btn"
1096                              on:click={updateConfiguration}
1097                              disabled={isLoading}
1098                          >
1099                              {#if isLoading}
1100                                  Updating...
1101                              {:else}
1102                                  Update Configuration
1103                              {/if}
1104                          </button>
1105                      </div>
1106                  </div>
1107              {/if}
1108          </div>
1109          {/if}
1110      {/if}
1111  </div>
1112  
1113  <style>
1114      .curation-view {
1115          width: 100%;
1116          max-width: 900px;
1117          margin: 0;
1118          padding: 20px;
1119          background: var(--header-bg);
1120          color: var(--text-color);
1121          border-radius: 8px;
1122      }
1123  
1124      .curation-view h2 {
1125          margin: 0 0 1.5rem 0;
1126          color: var(--text-color);
1127          font-size: 1.8rem;
1128          font-weight: 600;
1129      }
1130  
1131      .message {
1132          padding: 10px 15px;
1133          border-radius: 4px;
1134          margin-bottom: 20px;
1135      }
1136  
1137      .message.success {
1138          background-color: var(--success-bg);
1139          color: var(--success-text);
1140          border: 1px solid var(--success);
1141      }
1142  
1143      .message.error {
1144          background-color: var(--error-bg);
1145          color: var(--error-text);
1146          border: 1px solid var(--danger);
1147      }
1148  
1149      .message.info {
1150          background-color: var(--primary-bg);
1151          color: var(--text-color);
1152          border: 1px solid var(--info);
1153      }
1154  
1155      /* Setup Mode */
1156      .setup-section {
1157          background: var(--card-bg);
1158          border-radius: 8px;
1159          padding: 1.5em;
1160          border: 1px solid var(--border-color);
1161      }
1162  
1163      .setup-header h3 {
1164          margin: 0 0 0.5rem 0;
1165          color: var(--text-color);
1166      }
1167  
1168      .setup-header p {
1169          margin: 0 0 1.5rem 0;
1170          color: var(--text-color);
1171          opacity: 0.8;
1172      }
1173  
1174      .config-section {
1175          margin-bottom: 1.5rem;
1176          padding: 1rem;
1177          background: var(--bg-color);
1178          border-radius: 6px;
1179          border: 1px solid var(--border-color);
1180      }
1181  
1182      .config-section h4 {
1183          margin: 0 0 0.5rem 0;
1184          color: var(--text-color);
1185      }
1186  
1187      .help-text {
1188          margin: 0 0 1rem 0;
1189          color: var(--text-color);
1190          opacity: 0.7;
1191          font-size: 0.9em;
1192      }
1193  
1194      .category-grid {
1195          display: grid;
1196          grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
1197          gap: 0.75rem;
1198      }
1199  
1200      .category-item {
1201          display: flex;
1202          align-items: flex-start;
1203          gap: 0.75rem;
1204          padding: 0.75rem;
1205          background: var(--card-bg);
1206          border: 1px solid var(--border-color);
1207          border-radius: 6px;
1208          cursor: pointer;
1209          transition: all 0.2s;
1210      }
1211  
1212      .category-item:hover {
1213          border-color: var(--accent-color);
1214      }
1215  
1216      .category-item.selected {
1217          border-color: var(--success);
1218          background: var(--success-bg);
1219      }
1220  
1221      .category-item input[type="checkbox"] {
1222          margin-top: 0.25rem;
1223      }
1224  
1225      .category-info {
1226          display: flex;
1227          flex-direction: column;
1228          gap: 0.25rem;
1229      }
1230  
1231      .category-name {
1232          font-weight: 600;
1233          color: var(--text-color);
1234      }
1235  
1236      .category-desc {
1237          font-size: 0.85em;
1238          color: var(--text-color);
1239          opacity: 0.7;
1240      }
1241  
1242      .category-kinds {
1243          font-size: 0.8em;
1244          font-family: monospace;
1245          color: var(--text-color);
1246          opacity: 0.6;
1247      }
1248  
1249      .custom-kinds {
1250          margin-top: 1rem;
1251      }
1252  
1253      .custom-kinds label {
1254          display: block;
1255          margin-bottom: 0.5rem;
1256          color: var(--text-color);
1257          font-weight: 500;
1258      }
1259  
1260      .custom-kinds input {
1261          width: 100%;
1262          padding: 0.5rem;
1263          border: 1px solid var(--border-color);
1264          border-radius: 4px;
1265          background: var(--input-bg);
1266          color: var(--input-text-color);
1267      }
1268  
1269      .form-row {
1270          display: flex;
1271          gap: 1rem;
1272          flex-wrap: wrap;
1273      }
1274  
1275      .form-group {
1276          flex: 1;
1277          min-width: 150px;
1278      }
1279  
1280      .form-group label {
1281          display: block;
1282          margin-bottom: 0.5rem;
1283          color: var(--text-color);
1284          font-weight: 500;
1285      }
1286  
1287      .form-group input {
1288          width: 100%;
1289          padding: 0.5rem;
1290          border: 1px solid var(--border-color);
1291          border-radius: 4px;
1292          background: var(--input-bg);
1293          color: var(--input-text-color);
1294      }
1295  
1296      .publish-section {
1297          text-align: center;
1298          padding: 1rem;
1299      }
1300  
1301      .publish-btn {
1302          padding: 0.75rem 2rem;
1303          font-size: 1rem;
1304          font-weight: 600;
1305          background: var(--success);
1306          color: var(--text-color);
1307          border: none;
1308          border-radius: 6px;
1309          cursor: pointer;
1310          transition: all 0.2s;
1311      }
1312  
1313      .publish-btn:hover:not(:disabled) {
1314          filter: brightness(0.9);
1315      }
1316  
1317      .publish-btn:disabled {
1318          opacity: 0.6;
1319          cursor: not-allowed;
1320      }
1321  
1322      .publish-note {
1323          margin-top: 0.75rem;
1324          font-size: 0.85em;
1325          color: var(--text-color);
1326          opacity: 0.7;
1327      }
1328  
1329      /* Active Mode */
1330      .tabs {
1331          display: flex;
1332          border-bottom: 1px solid var(--border-color);
1333          margin-bottom: 1rem;
1334          flex-wrap: wrap;
1335      }
1336  
1337      .tab {
1338          padding: 0.75rem 1rem;
1339          border: none;
1340          background: none;
1341          cursor: pointer;
1342          border-bottom: 2px solid transparent;
1343          color: var(--text-color);
1344          font-size: 0.9rem;
1345          transition: all 0.2s;
1346      }
1347  
1348      .tab:hover {
1349          background: var(--button-hover-bg);
1350      }
1351  
1352      .tab.active {
1353          border-bottom-color: var(--accent-color);
1354          color: var(--accent-color);
1355      }
1356  
1357      .tab-content {
1358          min-height: 300px;
1359      }
1360  
1361      .section {
1362          background: var(--card-bg);
1363          border-radius: 8px;
1364          padding: 1.5em;
1365          border: 1px solid var(--border-color);
1366      }
1367  
1368      .section h3 {
1369          margin: 0 0 0.5rem 0;
1370          color: var(--text-color);
1371      }
1372  
1373      .add-form {
1374          display: flex;
1375          gap: 0.5rem;
1376          margin-bottom: 1rem;
1377          flex-wrap: wrap;
1378      }
1379  
1380      .add-form input {
1381          flex: 1;
1382          min-width: 150px;
1383          padding: 0.5rem;
1384          border: 1px solid var(--border-color);
1385          border-radius: 4px;
1386          background: var(--input-bg);
1387          color: var(--input-text-color);
1388      }
1389  
1390      .add-form button {
1391          padding: 0.5rem 1rem;
1392          background: var(--success);
1393          color: var(--text-color);
1394          border: none;
1395          border-radius: 4px;
1396          cursor: pointer;
1397      }
1398  
1399      .add-form button:disabled {
1400          opacity: 0.6;
1401          cursor: not-allowed;
1402      }
1403  
1404      .refresh-btn {
1405          margin-bottom: 1rem;
1406          padding: 0.5rem 1rem;
1407          background: var(--info);
1408          color: var(--text-color);
1409          border: none;
1410          border-radius: 4px;
1411          cursor: pointer;
1412      }
1413  
1414      .refresh-btn:disabled {
1415          opacity: 0.6;
1416          cursor: not-allowed;
1417      }
1418  
1419      .button-row {
1420          display: flex;
1421          gap: 0.5rem;
1422          margin-bottom: 1rem;
1423      }
1424  
1425      .scan-btn {
1426          padding: 0.5rem 1rem;
1427          background: var(--warning, #f0ad4e);
1428          color: var(--text-color);
1429          border: none;
1430          border-radius: 4px;
1431          cursor: pointer;
1432      }
1433  
1434      .scan-btn:disabled {
1435          opacity: 0.6;
1436          cursor: not-allowed;
1437      }
1438  
1439      .list {
1440          border: 1px solid var(--border-color);
1441          border-radius: 4px;
1442          max-height: 400px;
1443          overflow-y: auto;
1444          background: var(--bg-color);
1445      }
1446  
1447      .list-item {
1448          display: flex;
1449          justify-content: space-between;
1450          align-items: center;
1451          padding: 0.75rem 1rem;
1452          border-bottom: 1px solid var(--border-color);
1453          gap: 1rem;
1454      }
1455  
1456      .list-item:last-child {
1457          border-bottom: none;
1458      }
1459  
1460      .item-main {
1461          display: flex;
1462          flex-direction: column;
1463          gap: 0.25rem;
1464          flex: 1;
1465          min-width: 0;
1466      }
1467  
1468      .pubkey, .event-id, .ip {
1469          font-family: monospace;
1470          font-size: 0.9em;
1471          color: var(--text-color);
1472      }
1473  
1474      .note, .reason, .expires {
1475          font-size: 0.85em;
1476          color: var(--text-color);
1477          opacity: 0.7;
1478      }
1479  
1480      .event-count {
1481          font-size: 0.85em;
1482          color: var(--success);
1483          font-weight: 500;
1484      }
1485  
1486      .item-actions {
1487          display: flex;
1488          gap: 0.5rem;
1489          flex-shrink: 0;
1490      }
1491  
1492      .btn-success {
1493          padding: 0.35rem 0.75rem;
1494          background: var(--success);
1495          color: var(--text-color);
1496          border: none;
1497          border-radius: 4px;
1498          cursor: pointer;
1499          font-size: 0.85em;
1500      }
1501  
1502      .btn-danger {
1503          padding: 0.35rem 0.75rem;
1504          background: var(--danger);
1505          color: var(--text-color);
1506          border: none;
1507          border-radius: 4px;
1508          cursor: pointer;
1509          font-size: 0.85em;
1510      }
1511  
1512      .btn-delete-all {
1513          padding: 0.35rem 0.75rem;
1514          background: #8B0000;
1515          color: white;
1516          border: none;
1517          border-radius: 4px;
1518          cursor: pointer;
1519          font-size: 0.85em;
1520          font-weight: 600;
1521      }
1522  
1523      .btn-delete-all:hover:not(:disabled) {
1524          background: #660000;
1525      }
1526  
1527      .btn-delete-all:disabled {
1528          opacity: 0.5;
1529          cursor: not-allowed;
1530      }
1531  
1532      .empty {
1533          padding: 2rem;
1534          text-align: center;
1535          color: var(--text-color);
1536          opacity: 0.6;
1537          font-style: italic;
1538      }
1539  
1540      /* Clickable list items */
1541      .list-item.clickable {
1542          cursor: pointer;
1543          transition: background-color 0.2s;
1544      }
1545  
1546      .list-item.clickable:hover {
1547          background-color: var(--button-hover-bg);
1548      }
1549  
1550      /* User Detail View */
1551      .user-detail-view {
1552          background: var(--card-bg);
1553          border-radius: 8px;
1554          padding: 1.5em;
1555          border: 1px solid var(--border-color);
1556      }
1557  
1558      .detail-header {
1559          display: flex;
1560          justify-content: space-between;
1561          align-items: center;
1562          margin-bottom: 1.5rem;
1563          padding-bottom: 1rem;
1564          border-bottom: 1px solid var(--border-color);
1565          flex-wrap: wrap;
1566          gap: 1rem;
1567      }
1568  
1569      .detail-header-left {
1570          display: flex;
1571          align-items: center;
1572          gap: 1rem;
1573          flex-wrap: wrap;
1574      }
1575  
1576      .detail-header-left h3 {
1577          margin: 0;
1578          color: var(--text-color);
1579      }
1580  
1581      .detail-header-right {
1582          display: flex;
1583          gap: 0.5rem;
1584      }
1585  
1586      .back-btn {
1587          padding: 0.5rem 1rem;
1588          background: var(--bg-color);
1589          color: var(--text-color);
1590          border: 1px solid var(--border-color);
1591          border-radius: 4px;
1592          cursor: pointer;
1593          font-size: 0.9em;
1594      }
1595  
1596      .back-btn:hover {
1597          background: var(--button-hover-bg);
1598      }
1599  
1600      .detail-pubkey {
1601          font-family: monospace;
1602          font-size: 0.9em;
1603          color: var(--text-color);
1604          background: var(--bg-color);
1605          padding: 0.25rem 0.5rem;
1606          border-radius: 4px;
1607      }
1608  
1609      .detail-count {
1610          font-size: 0.85em;
1611          color: var(--success);
1612          font-weight: 500;
1613      }
1614  
1615      /* Events List */
1616      .events-list {
1617          max-height: 600px;
1618          overflow-y: auto;
1619      }
1620  
1621      .event-item {
1622          background: var(--bg-color);
1623          border: 1px solid var(--border-color);
1624          border-radius: 6px;
1625          padding: 1rem;
1626          margin-bottom: 0.75rem;
1627      }
1628  
1629      .event-header {
1630          display: flex;
1631          gap: 1rem;
1632          margin-bottom: 0.5rem;
1633          flex-wrap: wrap;
1634          align-items: center;
1635      }
1636  
1637      .event-kind {
1638          background: var(--accent-color);
1639          color: var(--text-color);
1640          padding: 0.2rem 0.5rem;
1641          border-radius: 4px;
1642          font-size: 0.8em;
1643          font-weight: 500;
1644      }
1645  
1646      .event-id {
1647          font-family: monospace;
1648          font-size: 0.8em;
1649          color: var(--text-color);
1650          opacity: 0.7;
1651      }
1652  
1653      .event-time {
1654          font-size: 0.8em;
1655          color: var(--text-color);
1656          opacity: 0.6;
1657      }
1658  
1659      .event-content {
1660          background: var(--card-bg);
1661          border-radius: 4px;
1662          padding: 0.75rem;
1663          overflow: hidden;
1664      }
1665  
1666      .event-content pre {
1667          margin: 0;
1668          white-space: pre-wrap;
1669          word-break: break-word;
1670          font-family: inherit;
1671          font-size: 0.9em;
1672          color: var(--text-color);
1673          max-height: 150px;
1674          overflow: hidden;
1675      }
1676  
1677      .event-content.expanded pre {
1678          max-height: none;
1679      }
1680  
1681      .expand-btn {
1682          margin-top: 0.5rem;
1683          padding: 0.25rem 0.5rem;
1684          background: transparent;
1685          color: var(--accent-color);
1686          border: 1px solid var(--accent-color);
1687          border-radius: 4px;
1688          cursor: pointer;
1689          font-size: 0.8em;
1690      }
1691  
1692      .expand-btn:hover {
1693          background: var(--accent-color);
1694          color: var(--text-color);
1695      }
1696  
1697      .load-more {
1698          text-align: center;
1699          padding: 1rem;
1700      }
1701  
1702      .load-more button {
1703          padding: 0.5rem 1.5rem;
1704          background: var(--info);
1705          color: var(--text-color);
1706          border: none;
1707          border-radius: 4px;
1708          cursor: pointer;
1709      }
1710  
1711      .load-more button:disabled {
1712          opacity: 0.6;
1713          cursor: not-allowed;
1714      }
1715  
1716      .loading {
1717          padding: 2rem;
1718          text-align: center;
1719          color: var(--text-color);
1720          opacity: 0.6;
1721      }
1722  </style>
1723