csr tangled client in solid-js
15

Configure Feed

Select the types of activity you want to include in your feed.

close topbar popups when clicked outside

dawn (May 20, 2026, 9:48 PM +0300) 2303308d 194c3201

+132 -84
+132 -84
src/layout.tsx
··· 2 2 import { Bell, LogOut, RotateCcw, Save, Settings } from 'lucide-solid'; 3 3 import { A } from '@solidjs/router'; 4 4 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 5 - import { For, Show, createEffect, createSignal, type Component, type JSX } from 'solid-js'; 5 + import { For, Show, createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; 6 6 7 7 import { Avatar, buttonStyles, inputStyles } from './components/common'; 8 8 import { clearApiCaches, resolveActor } from './lib/api'; ··· 10 10 import { useLiveEvents, type LiveEvent } from './lib/live-events'; 11 11 import { formatRelativeTime } from './lib/repo-utils'; 12 12 import { useAppSettings } from './lib/settings'; 13 + 14 + const TOPBAR_MENU_SELECTOR = 'details[data-topbar-menu]'; 15 + 16 + const closeOpenTopbarMenus = (root: HTMLElement, target: Node) => { 17 + root.querySelectorAll<HTMLDetailsElement>(TOPBAR_MENU_SELECTOR).forEach((menu) => { 18 + if (menu.open && !menu.contains(target)) { 19 + menu.open = false; 20 + } 21 + }); 22 + }; 13 23 14 24 const eventLabel = (event: LiveEvent) => { 15 25 if (event.source === 'sh.tangled.repo.issue:repo') { ··· 67 77 }; 68 78 69 79 return ( 70 - <details class="relative"> 80 + <details data-topbar-menu class="relative"> 71 81 <summary class={clsx('cursor-pointer list-none')} title="App settings"> 72 82 <Settings class="size-5 text-gray-500 dark:text-gray-400" /> 73 83 </summary> ··· 127 137 ); 128 138 }; 129 139 130 - const Topbar: Component = () => { 140 + const LoginMenu: Component = () => { 131 141 const auth = useAuth(); 132 - const live = useLiveEvents(); 133 142 const [identifier, setIdentifier] = createSignal(''); 134 143 144 + const onSignIn = async (event: SubmitEvent) => { 145 + event.preventDefault(); 146 + await auth.signIn(identifier()); 147 + }; 148 + 149 + return ( 150 + <details data-topbar-menu class="relative"> 151 + <summary class={clsx(buttonStyles(), 'cursor-pointer list-none')}>login</summary> 152 + <div class="absolute right-0 mt-3 w-72 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-lg z-50"> 153 + <form onSubmit={onSignIn} class="flex flex-col gap-3"> 154 + <input 155 + value={identifier()} 156 + onInput={(event) => setIdentifier(event.currentTarget.value)} 157 + placeholder="handle or did" 158 + class={inputStyles()} 159 + /> 160 + <button type="submit" class={clsx(buttonStyles('primary'), 'justify-center')}> 161 + continue with OAuth 162 + </button> 163 + <Show when={auth.error()}> 164 + <p class="text-sm text-red-500">{auth.error()}</p> 165 + </Show> 166 + </form> 167 + </div> 168 + </details> 169 + ); 170 + }; 171 + 172 + const NotificationsMenu: Component = () => { 173 + const live = useLiveEvents(); 174 + 175 + return ( 176 + <details data-topbar-menu class="relative"> 177 + <summary 178 + class="cursor-pointer list-none flex items-center gap-2" 179 + onClick={() => live.markRead()} 180 + > 181 + <div class="relative"> 182 + <Bell class="size-5 text-gray-500 dark:text-gray-400" /> 183 + <Show when={live.unread() > 0}> 184 + <span class="untangled-unread-badge absolute -top-2 -right-2 min-w-5 h-5 rounded-full bg-blue-600 text-white flex items-center justify-center px-1"> 185 + {Math.min(live.unread(), 99)} 186 + </span> 187 + </Show> 188 + </div> 189 + </summary> 190 + <div class="absolute right-0 mt-3 w-80 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg z-50 overflow-hidden"> 191 + <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 font-medium">live activity</div> 192 + <div class="max-h-96 overflow-auto"> 193 + <Show 194 + when={live.events().length > 0} 195 + fallback={<div class="px-4 py-6 text-sm text-gray-500 dark:text-gray-400">No live events yet.</div>} 196 + > 197 + <For each={live.events()}> 198 + {(event) => ( 199 + <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 text-sm"> 200 + <div class="font-medium break-all">{eventLabel(event)}</div> 201 + <div class="text-gray-500 dark:text-gray-400">{formatRelativeTime(event.receivedAt)}</div> 202 + </div> 203 + )} 204 + </For> 205 + </Show> 206 + </div> 207 + </div> 208 + </details> 209 + ); 210 + }; 211 + 212 + const UserProfileMenu: Component = () => { 213 + const auth = useAuth(); 214 + 135 215 const viewerQuery = createQuery(() => ({ 136 216 queryKey: ['viewer', auth.currentDid()], 137 217 enabled: Boolean(auth.currentDid()), 138 218 queryFn: async () => resolveActor(auth.currentDid()!), 139 219 })); 140 220 141 - const onSignIn = async (event: SubmitEvent) => { 142 - event.preventDefault(); 143 - await auth.signIn(identifier()); 144 - }; 221 + return ( 222 + <details data-topbar-menu class="relative"> 223 + <summary class="cursor-pointer list-none flex items-center gap-2"> 224 + <Avatar did={auth.currentDid()!} size="size-8 md:size-6" /> 225 + <span class="hidden md:inline">{viewerQuery.data?.handle ?? auth.currentDid()}</span> 226 + </summary> 227 + <div class="absolute right-0 mt-4 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg z-50 text-sm w-56"> 228 + <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700"> 229 + <div class="font-medium truncate">{viewerQuery.data?.handle ?? auth.currentDid()}</div> 230 + <div class="text-gray-500 dark:text-gray-400 truncate">{auth.currentDid()}</div> 231 + </div> 232 + <button 233 + type="button" 234 + class="w-full px-4 py-3 flex items-center gap-2 text-left hover:bg-gray-50 hover:dark:bg-gray-700/50" 235 + onClick={() => void auth.signOut()} 236 + > 237 + <LogOut class="size-4" /> 238 + logout 239 + </button> 240 + </div> 241 + </details> 242 + ); 243 + }; 244 + 245 + const Topbar: Component = () => { 246 + const auth = useAuth(); 247 + let topbar: HTMLElement | undefined; 248 + 249 + onMount(() => { 250 + const closeMenusForOutsideClick = (event: PointerEvent) => { 251 + const target = event.target; 252 + if (!topbar || !(target instanceof Node)) { 253 + return; 254 + } 255 + 256 + closeOpenTopbarMenus(topbar, target); 257 + }; 258 + 259 + document.addEventListener('pointerdown', closeMenusForOutsideClick); 260 + 261 + onCleanup(() => { 262 + document.removeEventListener('pointerdown', closeMenusForOutsideClick); 263 + }); 264 + }); 145 265 146 266 return ( 147 - <nav class="mx-auto space-x-4 px-6 py-2"> 267 + <nav ref={(element) => { topbar = element; }} class="mx-auto space-x-4 px-6 py-2"> 148 268 <div class="flex justify-between p-0 items-center"> 149 269 <A href="/" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 150 270 <img src="/static/logos/dolly.svg" alt="" class="size-8" /> ··· 156 276 <AppSettingsMenu /> 157 277 <Show 158 278 when={auth.currentDid()} 159 - fallback={ 160 - <details class="relative"> 161 - <summary class={clsx(buttonStyles(), 'cursor-pointer list-none')}>login</summary> 162 - <div class="absolute right-0 mt-3 w-72 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-lg z-50"> 163 - <form onSubmit={onSignIn} class="flex flex-col gap-3"> 164 - <input 165 - value={identifier()} 166 - onInput={(event) => setIdentifier(event.currentTarget.value)} 167 - placeholder="handle or did" 168 - class={inputStyles()} 169 - /> 170 - <button type="submit" class={clsx(buttonStyles('primary'), 'justify-center')}> 171 - continue with OAuth 172 - </button> 173 - <Show when={auth.error()}> 174 - <p class="text-sm text-red-500">{auth.error()}</p> 175 - </Show> 176 - </form> 177 - </div> 178 - </details> 179 - } 279 + fallback={<LoginMenu />} 180 280 > 181 - <details class="relative"> 182 - <summary 183 - class="cursor-pointer list-none flex items-center gap-2" 184 - onClick={() => live.markRead()} 185 - > 186 - <div class="relative"> 187 - <Bell class="size-5 text-gray-500 dark:text-gray-400" /> 188 - <Show when={live.unread() > 0}> 189 - <span class="untangled-unread-badge absolute -top-2 -right-2 min-w-5 h-5 rounded-full bg-blue-600 text-white flex items-center justify-center px-1"> 190 - {Math.min(live.unread(), 99)} 191 - </span> 192 - </Show> 193 - </div> 194 - </summary> 195 - <div class="absolute right-0 mt-3 w-80 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg z-50 overflow-hidden"> 196 - <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 font-medium">live activity</div> 197 - <div class="max-h-96 overflow-auto"> 198 - <Show 199 - when={live.events().length > 0} 200 - fallback={<div class="px-4 py-6 text-sm text-gray-500 dark:text-gray-400">No live events yet.</div>} 201 - > 202 - <For each={live.events()}> 203 - {(event) => ( 204 - <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 text-sm"> 205 - <div class="font-medium break-all">{eventLabel(event)}</div> 206 - <div class="text-gray-500 dark:text-gray-400">{formatRelativeTime(event.receivedAt)}</div> 207 - </div> 208 - )} 209 - </For> 210 - </Show> 211 - </div> 212 - </div> 213 - </details> 214 - 215 - <details class="relative"> 216 - <summary class="cursor-pointer list-none flex items-center gap-2"> 217 - <Avatar did={auth.currentDid()!} size="size-8 md:size-6" /> 218 - <span class="hidden md:inline">{viewerQuery.data?.handle ?? auth.currentDid()}</span> 219 - </summary> 220 - <div class="absolute right-0 mt-4 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg z-50 text-sm w-56"> 221 - <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700"> 222 - <div class="font-medium truncate">{viewerQuery.data?.handle ?? auth.currentDid()}</div> 223 - <div class="text-gray-500 dark:text-gray-400 truncate">{auth.currentDid()}</div> 224 - </div> 225 - <button 226 - type="button" 227 - class="w-full px-4 py-3 flex items-center gap-2 text-left hover:bg-gray-50 hover:dark:bg-gray-700/50" 228 - onClick={() => void auth.signOut()} 229 - > 230 - <LogOut class="size-4" /> 231 - logout 232 - </button> 233 - </div> 234 - </details> 281 + <NotificationsMenu /> 282 + <UserProfileMenu /> 235 283 </Show> 236 284 </div> 237 285 </div>