Header.svelte raw

   1  <script>
   2      import { isStandaloneMode, relayUrl, relayInfo, relayConnectionStatus, savedRelays, saveRelay, searchActive, notificationDropdownOpen } from "./stores.js";
   3      import { totalUnreadCount } from "./notificationStores.js";
   4      import { getApiBase, connectToRelay, normalizeWsUrl } from "./config.js";
   5  
   6      export let isDarkTheme = false;
   7      export let isLoggedIn = false;
   8      export let userRole = "";
   9      export let currentEffectiveRole = "";
  10  
  11      // Event dispatchers
  12      import { createEventDispatcher } from "svelte";
  13      const dispatch = createEventDispatcher();
  14  
  15      function toggleSearch() {
  16          searchActive.update(v => !v);
  17      }
  18  
  19      function toggleNotifications() {
  20          notificationDropdownOpen.update(v => !v);
  21      }
  22  
  23      // Dropdown state
  24      let showDropdown = false;
  25      let isConnecting = false;
  26      let connectingUrl = "";
  27  
  28      function toggleMobileMenu() {
  29          dispatch("toggleMobileMenu");
  30      }
  31  
  32      function openRelayModal() {
  33          dispatch("openRelayModal");
  34      }
  35  
  36      function toggleDropdown(event) {
  37          event.stopPropagation();
  38          showDropdown = !showDropdown;
  39      }
  40  
  41      function closeDropdown() {
  42          showDropdown = false;
  43      }
  44  
  45      async function switchToRelay(url) {
  46          console.log('[Header] switchToRelay called with:', url);
  47          if (isConnecting || url === $relayUrl) {
  48              console.log('[Header] Skipping - already connecting or same relay');
  49              return;
  50          }
  51  
  52          isConnecting = true;
  53          connectingUrl = url;
  54  
  55          try {
  56              console.log('[Header] Calling connectToRelay...');
  57              const result = await connectToRelay(url);
  58              console.log('[Header] connectToRelay result:', result);
  59              if (result.success) {
  60                  const wsUrl = normalizeWsUrl(url);
  61                  saveRelay(url, wsUrl);
  62                  dispatch("relayChanged", { info: result.info });
  63                  closeDropdown();
  64              } else {
  65                  console.log('[Header] Connection failed:', result.error);
  66              }
  67          } catch (error) {
  68              console.error("[Header] Failed to switch relay:", error);
  69          } finally {
  70              isConnecting = false;
  71              connectingUrl = "";
  72          }
  73      }
  74  
  75      function handleManageRelays(event) {
  76          event.stopPropagation();
  77          closeDropdown();
  78          openRelayModal();
  79      }
  80  
  81      // Close dropdown when clicking outside
  82      function handleClickOutside(event) {
  83          if (showDropdown) {
  84              closeDropdown();
  85          }
  86      }
  87  
  88      // Get display name for relay - always show host URL
  89      // Explicitly reference $relayUrl in reactive statement for proper tracking
  90      $: relayDisplayName = getRelayHost($relayUrl);
  91  
  92      function getRelayHost(storeUrl) {
  93          try {
  94              // In standalone mode, use the stored relay URL
  95              // In embedded mode (no stored URL), use the current origin
  96              const url = storeUrl || getApiBase();
  97              console.log("[Header] getRelayHost - storeUrl:", storeUrl, "resolved url:", url);
  98              const parsed = new URL(url);
  99              return parsed.host;
 100          } catch {
 101              return storeUrl || "local";
 102          }
 103      }
 104  
 105      function formatRelayUrl(url) {
 106          try {
 107              const parsed = new URL(url.startsWith('http') ? url : 'https://' + url);
 108              return parsed.host;
 109          } catch {
 110              return url;
 111          }
 112      }
 113  
 114      function isCurrentRelay(url) {
 115          return $relayUrl === url && $relayConnectionStatus === "connected";
 116      }
 117  </script>
 118  
 119  <svelte:window on:click={handleClickOutside} />
 120  
 121  <header class="main-header" class:dark-theme={isDarkTheme}>
 122      <div class="header-content">
 123          <button class="mobile-menu-btn" on:click={toggleMobileMenu} aria-label="Toggle menu">
 124              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 125                  <path d="M3 12h18M3 6h18M3 18h18" />
 126              </svg>
 127          </button>
 128  
 129          {#if isLoggedIn && userRole}
 130              <span class="permission-badge">{currentEffectiveRole}</span>
 131          {/if}
 132  
 133          <!-- Spacer to push right-side items -->
 134          <div class="header-spacer"></div>
 135  
 136          <!-- Relay indicator - dropdown only in standalone mode -->
 137          <div class="relay-dropdown-container">
 138              {#if $isStandaloneMode}
 139                  <button
 140                      class="relay-indicator"
 141                      on:click={toggleDropdown}
 142                      title="Click to switch relays"
 143                  >
 144                      <span class="relay-status" class:connected={$relayConnectionStatus === "connected"} class:error={$relayConnectionStatus === "error"}></span>
 145                      <span class="relay-name">{relayDisplayName}</span>
 146                      <span class="dropdown-arrow" class:open={showDropdown}>&#9662;</span>
 147                  </button>
 148  
 149                  {#if showDropdown}
 150                      <!-- svelte-ignore a11y-click-events-have-key-events -->
 151                      <!-- svelte-ignore a11y-no-static-element-interactions -->
 152                      <div class="relay-dropdown" on:click|stopPropagation>
 153                          {#if $savedRelays.length > 0}
 154                              <div class="dropdown-section">
 155                                  <div class="dropdown-label">Saved Relays</div>
 156                                  {#each $savedRelays as relay}
 157                                      <button
 158                                          class="dropdown-item"
 159                                          class:current={isCurrentRelay(relay.url)}
 160                                          class:connecting={connectingUrl === relay.url}
 161                                          on:click={() => switchToRelay(relay.url)}
 162                                          disabled={isConnecting}
 163                                      >
 164                                          <span class="item-status" class:connected={isCurrentRelay(relay.url)}></span>
 165                                          <span class="item-url-label">{relay.name}</span>
 166                                          {#if connectingUrl === relay.url}
 167                                              <span class="connecting-indicator">...</span>
 168                                          {/if}
 169                                      </button>
 170                                  {/each}
 171                              </div>
 172                              <div class="dropdown-divider"></div>
 173                          {/if}
 174                          <button class="dropdown-item manage-btn" on:click={handleManageRelays}>
 175                              Manage Relays...
 176                          </button>
 177                      </div>
 178                  {/if}
 179              {:else}
 180                  <!-- Embedded mode: static indicator, no dropdown -->
 181                  <div class="relay-indicator static" title="Connected to {relayDisplayName}">
 182                      <span class="relay-status" class:connected={$relayConnectionStatus === "connected"} class:error={$relayConnectionStatus === "error"}></span>
 183                      <span class="relay-name">{relayDisplayName}</span>
 184                  </div>
 185              {/if}
 186          </div>
 187  
 188          <!-- Search button -->
 189          <button class="header-icon-btn" on:click={toggleSearch} title="Search" aria-label="Search">
 190              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
 191                  <circle cx="11" cy="11" r="8" />
 192                  <path d="M21 21l-4.35-4.35" />
 193              </svg>
 194          </button>
 195  
 196          <!-- Notification bell -->
 197          <button class="header-icon-btn notification-btn" on:click={toggleNotifications} title="Notifications" aria-label="Notifications">
 198              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
 199                  <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
 200                  <path d="M13.73 21a2 2 0 0 1-3.46 0" />
 201              </svg>
 202              {#if $totalUnreadCount > 0}
 203                  <span class="notification-badge">{$totalUnreadCount > 99 ? '99+' : $totalUnreadCount}</span>
 204              {/if}
 205          </button>
 206      </div>
 207  </header>
 208  
 209  <style>
 210      .main-header {
 211          color: var(--text-color);
 212          position: fixed;
 213          top: 0;
 214          left: 0;
 215          right: 0;
 216          height: 3em;
 217          background: var(--header-bg);
 218          border: 0;
 219          z-index: 1000;
 220          display: flex;
 221          align-items: stretch;
 222          padding: 0 0.25em;
 223      }
 224  
 225      .header-content {
 226          display: flex;
 227          align-items: stretch;
 228          width: 100%;
 229          padding: 0;
 230          margin: 0;
 231      }
 232  
 233      .mobile-menu-btn {
 234          display: none;
 235          align-items: center;
 236          justify-content: center;
 237          background: transparent;
 238          border: none;
 239          color: var(--text-color);
 240          cursor: pointer;
 241          padding: 0.5em;
 242          margin-right: 0.25em;
 243      }
 244  
 245      .mobile-menu-btn svg {
 246          width: 1.5em;
 247          height: 1.5em;
 248      }
 249  
 250      .mobile-menu-btn:hover {
 251          background: var(--card-bg);
 252          border-radius: 4px;
 253      }
 254  
 255      @media (max-width: 640px) {
 256          .mobile-menu-btn {
 257              display: flex;
 258          }
 259      }
 260  
 261      .permission-badge {
 262          background: var(--primary);
 263          color: #000;
 264          padding: 0.2em 0.5em;
 265          border-radius: 0.5em;
 266          font-size: 0.7em;
 267          font-weight: 500;
 268          text-transform: uppercase;
 269          letter-spacing: 0.5px;
 270          align-self: center;
 271          margin-left: 0.5em;
 272      }
 273  
 274      .header-spacer {
 275          flex: 1;
 276      }
 277  
 278      .header-icon-btn {
 279          display: flex;
 280          align-items: center;
 281          justify-content: center;
 282          background: transparent;
 283          border: none;
 284          color: var(--text-color);
 285          cursor: pointer;
 286          padding: 0 0.6em;
 287          align-self: stretch;
 288          transition: background 0.15s;
 289          position: relative;
 290      }
 291  
 292      .header-icon-btn:hover {
 293          background: var(--button-hover-bg);
 294      }
 295  
 296      .header-icon-btn svg {
 297          width: 1.25em;
 298          height: 1.25em;
 299      }
 300  
 301      .notification-badge {
 302          position: absolute;
 303          top: 0.4em;
 304          right: 0.3em;
 305          background: var(--primary);
 306          color: #000;
 307          font-size: 0.55rem;
 308          font-weight: 700;
 309          padding: 0.1em 0.35em;
 310          border-radius: 10px;
 311          min-width: 1em;
 312          text-align: center;
 313          line-height: 1.3;
 314      }
 315  
 316      /* Relay dropdown container */
 317      .relay-dropdown-container {
 318          position: relative;
 319          align-self: center;
 320      }
 321  
 322      /* Relay indicator */
 323      .relay-indicator {
 324          display: flex;
 325          align-items: center;
 326          gap: 6px;
 327          padding: 4px 10px;
 328          margin: 0 8px;
 329          background: var(--muted);
 330          border: 1px solid var(--border-color);
 331          border-radius: 4px;
 332          cursor: pointer;
 333          font-size: 0.85em;
 334          color: var(--text-color);
 335          transition: background-color 0.2s, border-color 0.2s;
 336      }
 337  
 338      .relay-indicator:hover:not(.static) {
 339          background: var(--card-bg);
 340          border-color: var(--primary);
 341      }
 342  
 343      .relay-indicator.static {
 344          cursor: default;
 345      }
 346  
 347      .relay-status {
 348          width: 8px;
 349          height: 8px;
 350          border-radius: 50%;
 351          background: var(--warning);
 352          flex-shrink: 0;
 353      }
 354  
 355      .relay-status.connected {
 356          background: var(--success);
 357      }
 358  
 359      .relay-status.error {
 360          background: var(--danger);
 361      }
 362  
 363      .relay-name {
 364          white-space: nowrap;
 365          overflow: hidden;
 366          text-overflow: ellipsis;
 367          max-width: 200px;
 368      }
 369  
 370      .dropdown-arrow {
 371          font-size: 0.7em;
 372          transition: transform 0.2s;
 373          margin-left: 2px;
 374      }
 375  
 376      .dropdown-arrow.open {
 377          transform: rotate(180deg);
 378      }
 379  
 380      /* Dropdown menu */
 381      .relay-dropdown {
 382          position: absolute;
 383          top: calc(100% + 4px);
 384          left: 0;
 385          min-width: 250px;
 386          background: var(--bg-color);
 387          border: 1px solid var(--border-color);
 388          border-radius: 6px;
 389          box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
 390          z-index: 1001;
 391          overflow: hidden;
 392      }
 393  
 394      .dropdown-section {
 395          padding: 4px 0;
 396      }
 397  
 398      .dropdown-label {
 399          padding: 6px 12px;
 400          font-size: 0.75em;
 401          color: var(--muted-foreground);
 402          text-transform: uppercase;
 403          letter-spacing: 0.5px;
 404          font-weight: 500;
 405      }
 406  
 407      .dropdown-item {
 408          display: flex;
 409          align-items: center;
 410          gap: 8px;
 411          width: 100%;
 412          padding: 8px 12px;
 413          background: transparent;
 414          border: none;
 415          cursor: pointer;
 416          text-align: left;
 417          color: var(--text-color);
 418          font-size: 0.9em;
 419          transition: background-color 0.15s;
 420      }
 421  
 422      .dropdown-item:hover:not(:disabled) {
 423          background: var(--tab-hover-bg);
 424      }
 425  
 426      .dropdown-item:disabled {
 427          opacity: 0.6;
 428          cursor: not-allowed;
 429      }
 430  
 431      .dropdown-item.current {
 432          background: rgba(16, 185, 129, 0.1);
 433      }
 434  
 435      .dropdown-item.connecting {
 436          background: rgba(234, 179, 8, 0.1);
 437      }
 438  
 439      .item-status {
 440          width: 6px;
 441          height: 6px;
 442          border-radius: 50%;
 443          background: var(--muted-foreground);
 444          flex-shrink: 0;
 445      }
 446  
 447      .item-status.connected {
 448          background: var(--success);
 449      }
 450  
 451      .item-url-label {
 452          flex: 1;
 453          font-family: monospace;
 454          font-size: 0.85em;
 455          white-space: nowrap;
 456          overflow: hidden;
 457          text-overflow: ellipsis;
 458      }
 459  
 460      .connecting-indicator {
 461          color: var(--warning);
 462          font-weight: bold;
 463      }
 464  
 465      .dropdown-divider {
 466          height: 1px;
 467          background: var(--border-color);
 468          margin: 4px 0;
 469      }
 470  
 471      .manage-btn {
 472          color: var(--primary);
 473          font-weight: 500;
 474      }
 475  </style>
 476