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}>▾</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