SidebarAccordion.svelte raw
1 <script>
2 import { createEventDispatcher } from 'svelte';
3 import { activeView, expandedSection, userMenuOpen } from './stores.js';
4 import { totalUnreadDMs, totalUnreadChannels } from './chatStores.js';
5 import { totalUnreadCount } from './notificationStores.js';
6
7 export let isLoggedIn = false;
8 export let userProfile = null;
9 export let userPubkey = "";
10 export let currentEffectiveRole = "";
11 export let version = "";
12 export let mobileOpen = false;
13
14 // Admin feature flags
15 export let aclMode = "";
16 export let sprocketEnabled = false;
17 export let policyEnabled = false;
18 export let nrcEnabled = false;
19 export let blossomEnabled = true;
20 export let isOrlyRelay = true;
21
22 const dispatch = createEventDispatcher();
23
24 // Navigation structure
25 const sections = [
26 {
27 id: "feed",
28 icon: "⚡",
29 label: "Feed",
30 children: null, // no children = direct nav
31 },
32 {
33 id: "chat",
34 icon: "💬",
35 label: "Chat",
36 children: [
37 { id: "chat-inbox", label: "Inbox" },
38 { id: "chat-channels", label: "Channels" },
39 ],
40 },
41 {
42 id: "library",
43 icon: "📚",
44 label: "Library",
45 children: [
46 { id: "library-my", label: "My Library" },
47 { id: "library-bookmarks", label: "Bookmarks" },
48 { id: "library-new", label: "New" },
49 ],
50 },
51 ];
52
53 // Build admin section dynamically based on permissions
54 $: adminChildren = buildAdminChildren(isLoggedIn, currentEffectiveRole, aclMode,
55 sprocketEnabled, policyEnabled, nrcEnabled, blossomEnabled, isOrlyRelay);
56
57 function buildAdminChildren(loggedIn, role, acl, sprocket, policy, nrc, blossom, orly) {
58 if (!loggedIn) return [];
59 const items = [];
60 items.push({ id: "admin-export", label: "Export" });
61 if (role === "admin" || role === "owner") {
62 items.push({ id: "admin-import", label: "Import" });
63 }
64 if (role === "read" || role === "write" || role === "admin" || role === "owner") {
65 items.push({ id: "admin-events", label: "Events" });
66 }
67 if (blossom) {
68 items.push({ id: "admin-blossom", label: "Blossom" });
69 }
70 if (role !== "read") {
71 items.push({ id: "admin-compose", label: "Compose" });
72 }
73 items.push({ id: "admin-recovery", label: "Recovery" });
74 if (role === "owner" && orly) {
75 if (acl === "managed") items.push({ id: "admin-managed-acl", label: "Managed ACL" });
76 if (acl === "curating") items.push({ id: "admin-curation", label: "Curation" });
77 if (sprocket) items.push({ id: "admin-sprocket", label: "Sprocket" });
78 if (policy) items.push({ id: "admin-policy", label: "Policy" });
79 if (nrc) items.push({ id: "admin-relay-connect", label: "Relay Connect" });
80 items.push({ id: "admin-logs", label: "Logs" });
81 }
82 return items;
83 }
84
85 $: allSections = [
86 ...sections,
87 ...(adminChildren.length > 0 ? [{
88 id: "admin",
89 icon: "⚙️",
90 label: "Admin",
91 children: adminChildren,
92 }] : []),
93 ];
94
95 function toggleSection(sectionId) {
96 expandedSection.update(current => current === sectionId ? null : sectionId);
97 }
98
99 function navigate(viewId) {
100 activeView.set(viewId);
101 dispatch('navigate', viewId);
102 // Close mobile menu on navigation
103 if (mobileOpen) dispatch('closeMobileMenu');
104 }
105
106 function handleSectionClick(section) {
107 if (section.children) {
108 toggleSection(section.id);
109 } else {
110 navigate(section.id);
111 }
112 }
113
114 function handleChildClick(childId) {
115 navigate(childId);
116 }
117
118 function getUnreadBadge(sectionId) {
119 if (sectionId === "chat") return $totalUnreadDMs + $totalUnreadChannels;
120 return 0;
121 }
122
123 function getChildUnreadBadge(childId) {
124 if (childId === "chat-inbox") return $totalUnreadDMs;
125 if (childId === "chat-channels") return $totalUnreadChannels;
126 return 0;
127 }
128
129 function handleUserClick() {
130 userMenuOpen.update(v => !v);
131 dispatch('toggleUserMenu');
132 }
133
134 function handleLogoClick() {
135 dispatch('showAbout');
136 }
137
138 // Truncate display name
139 function displayName(profile, pubkey) {
140 if (profile?.name) return profile.name;
141 if (profile?.display_name) return profile.display_name;
142 if (pubkey) return pubkey.slice(0, 8) + '...';
143 return 'Anonymous';
144 }
145 </script>
146
147 <nav class="sidebar-accordion" class:mobile-open={mobileOpen}>
148 {#if mobileOpen}
149 <div class="mobile-overlay" on:click={() => dispatch('closeMobileMenu')}></div>
150 {/if}
151
152 <div class="sidebar-content">
153 <!-- User avatar / login button at top -->
154 <div class="sidebar-user">
155 {#if isLoggedIn}
156 <button class="user-button" on:click={handleUserClick}>
157 {#if userProfile?.picture}
158 <img src={userProfile.picture} alt="avatar" class="user-avatar" />
159 {:else}
160 <div class="user-avatar-placeholder">
161 {displayName(userProfile, userPubkey).charAt(0).toUpperCase()}
162 </div>
163 {/if}
164 <span class="user-name">{displayName(userProfile, userPubkey)}</span>
165 </button>
166 {:else}
167 <button class="login-button" on:click={() => dispatch('openLoginModal')}>
168 Log in
169 </button>
170 {/if}
171 </div>
172
173 <!-- Navigation sections -->
174 <div class="nav-sections">
175 {#each allSections as section}
176 <div class="nav-section" class:expanded={$expandedSection === section.id}>
177 <button
178 class="section-header"
179 class:active={$activeView === section.id || (section.children && section.children.some(c => $activeView === c.id))}
180 on:click={() => handleSectionClick(section)}
181 >
182 <span class="section-icon">{section.icon}</span>
183 <span class="section-label">{section.label}</span>
184 {#if section.children}
185 <span class="section-chevron">{$expandedSection === section.id ? '▾' : '▸'}</span>
186 {/if}
187 {#if getUnreadBadge(section.id) > 0}
188 <span class="unread-badge">{getUnreadBadge(section.id)}</span>
189 {/if}
190 </button>
191
192 {#if section.children && $expandedSection === section.id}
193 <div class="section-children">
194 {#each section.children as child}
195 <button
196 class="child-item"
197 class:active={$activeView === child.id}
198 on:click={() => handleChildClick(child.id)}
199 >
200 <span class="child-label">{child.label}</span>
201 {#if getChildUnreadBadge(child.id) > 0}
202 <span class="unread-badge small">{getChildUnreadBadge(child.id)}</span>
203 {/if}
204 </button>
205 {/each}
206 <div class="section-boundary"></div>
207 </div>
208 {/if}
209 </div>
210 {/each}
211 </div>
212
213 <!-- Logo at bottom -->
214 <div class="sidebar-footer">
215 <button class="logo-button" on:click={handleLogoClick} title="About smesh">
216 <span class="logo-text">smesh</span>
217 {#if version}
218 <span class="version-text">{version}</span>
219 {/if}
220 </button>
221 </div>
222 </div>
223 </nav>
224
225 <style>
226 .sidebar-accordion {
227 position: fixed;
228 top: 3em;
229 left: 0;
230 bottom: 0;
231 width: 200px;
232 background: var(--sidebar-bg);
233 border-right: 1px solid var(--border-color);
234 display: flex;
235 flex-direction: column;
236 z-index: 100;
237 transition: transform 0.2s ease;
238 }
239
240 .sidebar-content {
241 display: flex;
242 flex-direction: column;
243 height: 100%;
244 overflow-y: auto;
245 }
246
247 /* User section */
248 .sidebar-user {
249 padding: 0.75em;
250 border-bottom: 1px solid var(--border-color);
251 }
252
253 .user-button {
254 display: flex;
255 align-items: center;
256 gap: 0.5em;
257 width: 100%;
258 padding: 0.5em;
259 background: none;
260 border: none;
261 border-radius: 8px;
262 cursor: pointer;
263 color: var(--text-color);
264 transition: background 0.15s;
265 }
266
267 .user-button:hover {
268 background: var(--button-hover-bg);
269 }
270
271 .user-avatar {
272 width: 32px;
273 height: 32px;
274 border-radius: 50%;
275 object-fit: cover;
276 }
277
278 .user-avatar-placeholder {
279 width: 32px;
280 height: 32px;
281 border-radius: 50%;
282 background: var(--primary);
283 color: #000;
284 display: flex;
285 align-items: center;
286 justify-content: center;
287 font-weight: bold;
288 font-size: 0.9rem;
289 }
290
291 .user-name {
292 font-size: 0.85rem;
293 font-weight: 500;
294 overflow: hidden;
295 text-overflow: ellipsis;
296 white-space: nowrap;
297 }
298
299 .login-button {
300 width: 100%;
301 padding: 0.6em;
302 background: var(--primary);
303 color: #000;
304 border: none;
305 border-radius: 6px;
306 cursor: pointer;
307 font-weight: 600;
308 font-size: 0.85rem;
309 transition: filter 0.15s;
310 }
311
312 .login-button:hover {
313 filter: brightness(0.9);
314 }
315
316 /* Navigation sections */
317 .nav-sections {
318 flex: 1;
319 padding: 0.5em 0;
320 }
321
322 .nav-section {
323 margin: 0;
324 }
325
326 .section-header {
327 display: flex;
328 align-items: center;
329 gap: 0.5em;
330 width: 100%;
331 padding: 0.6em 0.75em;
332 background: none;
333 border: none;
334 cursor: pointer;
335 color: var(--text-color);
336 font-size: 0.85rem;
337 font-weight: 500;
338 transition: background 0.15s;
339 text-align: left;
340 }
341
342 .section-header:hover {
343 background: var(--button-hover-bg);
344 }
345
346 .section-header.active {
347 color: var(--primary);
348 background: var(--primary-bg);
349 }
350
351 .section-icon {
352 font-size: 1rem;
353 width: 1.5em;
354 text-align: center;
355 }
356
357 .section-label {
358 flex: 1;
359 }
360
361 .section-chevron {
362 font-size: 0.7rem;
363 color: var(--text-muted);
364 }
365
366 .unread-badge {
367 background: var(--primary);
368 color: #000;
369 font-size: 0.65rem;
370 font-weight: 700;
371 padding: 0.1em 0.4em;
372 border-radius: 10px;
373 min-width: 1.2em;
374 text-align: center;
375 }
376
377 .unread-badge.small {
378 font-size: 0.6rem;
379 padding: 0.05em 0.35em;
380 }
381
382 /* Children */
383 .section-children {
384 padding-left: 0;
385 background: var(--primary-bg);
386 }
387
388 .child-item {
389 display: flex;
390 align-items: center;
391 gap: 0.5em;
392 width: 100%;
393 padding: 0.45em 0.75em 0.45em 2.75em;
394 background: none;
395 border: none;
396 cursor: pointer;
397 color: var(--text-color);
398 font-size: 0.8rem;
399 transition: background 0.15s;
400 text-align: left;
401 }
402
403 .child-item:hover {
404 background: var(--button-hover-bg);
405 }
406
407 .child-item.active {
408 color: var(--primary);
409 font-weight: 600;
410 }
411
412 .child-label {
413 flex: 1;
414 }
415
416 .section-boundary {
417 height: 1px;
418 background: var(--border-color);
419 margin: 0.25em 0.75em;
420 }
421
422 /* Footer */
423 .sidebar-footer {
424 padding: 0.5em;
425 border-top: 1px solid var(--border-color);
426 text-align: right;
427 }
428
429 .logo-button {
430 background: none;
431 border: none;
432 cursor: pointer;
433 padding: 0.4em 0.6em;
434 border-radius: 6px;
435 transition: background 0.15s;
436 }
437
438 .logo-button:hover {
439 background: var(--button-hover-bg);
440 }
441
442 .logo-text {
443 font-size: 0.85rem;
444 font-weight: 700;
445 color: var(--primary);
446 letter-spacing: 0.05em;
447 }
448
449 .version-text {
450 display: block;
451 font-size: 0.6rem;
452 color: var(--text-muted);
453 margin-top: 0.1em;
454 }
455
456 /* Mobile */
457 .mobile-overlay {
458 position: fixed;
459 top: 0;
460 left: 0;
461 right: 0;
462 bottom: 0;
463 background: rgba(0, 0, 0, 0.5);
464 z-index: -1;
465 }
466
467 @media (max-width: 1280px) {
468 .sidebar-accordion {
469 width: 60px;
470 }
471
472 .section-label,
473 .section-chevron,
474 .user-name,
475 .child-label,
476 .version-text,
477 .logo-text {
478 display: none;
479 }
480
481 .section-header {
482 justify-content: center;
483 padding: 0.6em;
484 }
485
486 .section-icon {
487 width: auto;
488 }
489
490 .section-children {
491 display: none;
492 }
493
494 .sidebar-user {
495 padding: 0.5em;
496 }
497
498 .user-button {
499 justify-content: center;
500 padding: 0.5em;
501 }
502
503 .user-avatar-placeholder,
504 .user-avatar {
505 width: 28px;
506 height: 28px;
507 }
508
509 .sidebar-footer {
510 text-align: center;
511 }
512 }
513
514 @media (max-width: 640px) {
515 .sidebar-accordion {
516 width: 250px;
517 transform: translateX(-100%);
518 top: 0;
519 z-index: 1000;
520 }
521
522 .sidebar-accordion.mobile-open {
523 transform: translateX(0);
524 }
525
526 .section-label,
527 .section-chevron,
528 .user-name,
529 .child-label,
530 .version-text,
531 .logo-text {
532 display: inline;
533 }
534
535 .section-header {
536 justify-content: flex-start;
537 padding: 0.6em 0.75em;
538 }
539
540 .section-children {
541 display: block;
542 }
543
544 .sidebar-footer {
545 text-align: right;
546 }
547 }
548 </style>
549