BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
at main 334 lines 13 kB view raw
1import { useAppPreferences } from "$/contexts/app-preferences"; 2import { useAppSession } from "$/contexts/app-session"; 3import { useAppShellUi } from "$/contexts/app-shell-ui"; 4import { useNavigationHistory } from "$/lib/navigation-history"; 5import { normalizeThemeSetting } from "$/lib/theme"; 6import type { Theme } from "$/lib/types"; 7import { useLocation } from "@solidjs/router"; 8import { openUrl } from "@tauri-apps/plugin-opener"; 9import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 10import { AccountSwitcher } from "../account/AccountSwitcher"; 11import { HistoryControls } from "../shared/HistoryControls"; 12import { Icon, RailFoldIcon } from "../shared/Icon"; 13import { Wordmark } from "../Wordmark"; 14import { RailActionButton, RailButton } from "./AppRailButton"; 15 16function RailHeader(props: { collapsed: boolean; onToggleCollapse: () => void }) { 17 return ( 18 <> 19 <div 20 class="flex shrink-0 items-center justify-between gap-3 max-lg:min-w-0 max-lg:justify-self-start" 21 classList={{ "w-full flex-col gap-3": props.collapsed }}> 22 <Wordmark compact={props.collapsed} iconClass="text-primary" /> 23 24 <div class="max-lg:hidden"> 25 <button 26 class="ui-control ui-control-hoverable inline-flex h-10 w-10 items-center justify-center rounded-full" 27 type="button" 28 aria-label={props.collapsed ? "Expand app rail" : "Collapse app rail"} 29 aria-pressed={props.collapsed} 30 onClick={() => props.onToggleCollapse()}> 31 <RailFoldIcon kind={props.collapsed ? "open" : "close"} /> 32 </button> 33 </div> 34 </div> 35 </> 36 ); 37} 38 39function OverflowMenuButton(props: { hasSession: boolean }) { 40 const [open, setOpen] = createSignal(false); 41 const [menuPos, setMenuPos] = createSignal({ top: 0, left: 0 }); 42 const location = useLocation(); 43 let containerRef: HTMLDivElement | undefined; 44 let buttonRef: HTMLButtonElement | undefined; 45 46 const isOverflowActive = createMemo(() => 47 ["/saved", "/deck", "/explorer", "/settings"].some((p) => location.pathname.startsWith(p)) 48 ); 49 50 createEffect(() => { 51 void `${location.pathname}${location.search}`; 52 setOpen(false); 53 }); 54 55 function onOutsideClick(e: MouseEvent) { 56 if (containerRef && !containerRef.contains(e.target as Node)) { 57 setOpen(false); 58 } 59 } 60 function onResize() { 61 setOpen(false); 62 } 63 64 onMount(() => { 65 document.addEventListener("mousedown", onOutsideClick); 66 window.addEventListener("resize", onResize); 67 onCleanup(() => { 68 document.removeEventListener("mousedown", onOutsideClick); 69 window.removeEventListener("resize", onResize); 70 }); 71 }); 72 73 function handleToggle() { 74 if (!open() && buttonRef) { 75 const rect = buttonRef.getBoundingClientRect(); 76 const preferredLeft = rect.left; 77 const maxLeft = window.innerWidth - 208; 78 setMenuPos({ top: rect.bottom + 8, left: Math.max(8, Math.min(preferredLeft, maxLeft)) }); 79 } 80 setOpen((v) => !v); 81 } 82 83 return ( 84 <div ref={el => (containerRef = el)}> 85 <button 86 ref={el => (buttonRef = el)} 87 type="button" 88 aria-label="More navigation" 89 aria-expanded={open()} 90 aria-haspopup="menu" 91 onClick={handleToggle} 92 class="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-surface-bright hover:text-on-surface" 93 classList={{ "bg-surface-container text-primary": open() || isOverflowActive() }}> 94 <span class="flex items-center"> 95 <i class="i-ri-more-2-line text-[1.25rem]" /> 96 </span> 97 </button> 98 <Show when={open()}> 99 <div 100 role="menu" 101 style={{ position: "fixed", top: `${menuPos().top}px`, left: `${menuPos().left}px` }} 102 class="ui-overlay-card z-50 min-w-48 rounded-xl border ui-outline-subtle bg-surface-container p-1.5 backdrop-blur-[20px]"> 103 <Show when={props.hasSession}> 104 <RailButton end compact={false} href="/saved" label="Saved" icon="bookmark" /> 105 <RailButton end compact={false} href="/deck" label="Deck" icon="deck" /> 106 <RailButton end compact={false} href="/explorer" label="AT Explorer" icon="explorer" /> 107 <RailButton end compact={false} href="/settings" label="Settings" icon="settings" /> 108 <hr class="my-1 border ui-outline-subtle" /> 109 <RailActionButton 110 compact 111 icon="heart" 112 label="Support" 113 onClick={() => void openUrl("https://github.com/sponsors/desertthunder")} /> 114 </Show> 115 </div> 116 </Show> 117 </div> 118 ); 119} 120 121function RailNavigation( 122 props: { collapsed: boolean; hasSession: boolean; narrow: boolean; unreadNotifications: number }, 123) { 124 const useOverflowMenu = () => props.narrow || props.collapsed; 125 126 return ( 127 <div class="grid gap-1 max-lg:flex max-lg:min-w-0 max-lg:flex-1 max-lg:items-center max-lg:gap-2 max-lg:overflow-x-auto max-lg:overscroll-contain max-lg:[scrollbar-width:none] max-lg:[&::-webkit-scrollbar]:hidden"> 128 <Show 129 when={props.hasSession} 130 fallback={<RailButton end compact={props.collapsed} href="/auth" label="Accounts" icon="profile" />}> 131 <RailButton end compact={props.collapsed} href="/timeline" label="Timeline" icon="timeline" /> 132 <RailButton compact={props.collapsed} href="/profile" label="Profile" icon="profile" /> 133 <RailButton end compact={props.collapsed} href="/search" label="Search" icon="search" /> 134 <Show when={!useOverflowMenu()}> 135 <RailButton end compact={props.collapsed} href="/saved" label="Saved" icon="bookmark" /> 136 </Show> 137 <RailButton 138 end 139 badge={props.unreadNotifications} 140 compact={props.collapsed} 141 href="/notifications" 142 label="Notifications" 143 icon="notifications" /> 144 <RailButton end compact={props.collapsed} href="/messages" label="Messages" icon="messages" /> 145 <Show 146 when={useOverflowMenu()} 147 fallback={ 148 <> 149 <RailButton end compact={props.collapsed} href="/deck" label="Deck" icon="deck" /> 150 <RailButton end compact={props.collapsed} href="/explorer" label="AT Explorer" icon="explorer" /> 151 <RailButton end compact={props.collapsed} href="/settings" label="Settings" icon="settings" /> 152 </> 153 }> 154 <OverflowMenuButton hasSession={props.hasSession} /> 155 </Show> 156 </Show> 157 </div> 158 ); 159} 160 161const RAIL_THEME_OPTIONS: Array<{ value: Theme; label: string; iconClass: string }> = [ 162 { value: "auto", label: "System", iconClass: "i-ri-computer-line" }, 163 { value: "light", label: "Light", iconClass: "i-ri-sun-line" }, 164 { value: "dark", label: "Dark", iconClass: "i-ri-moon-clear-line" }, 165]; 166 167function iconClassForTheme(theme: Theme) { 168 return RAIL_THEME_OPTIONS.find((option) => option.value === theme)?.iconClass ?? "i-ri-computer-line"; 169} 170 171function RailThemeMenu( 172 props: { collapsed: boolean; currentTheme: Theme; onChangeTheme: (theme: Theme) => Promise<void> }, 173) { 174 const [open, setOpen] = createSignal(false); 175 const [menuPos, setMenuPos] = createSignal({ top: 0, left: 0 }); 176 const compact = () => props.collapsed; 177 let containerRef: HTMLDivElement | undefined; 178 let buttonRef: HTMLButtonElement | undefined; 179 180 function closeMenu() { 181 setOpen(false); 182 } 183 184 function onOutsideClick(event: MouseEvent) { 185 if (containerRef && !containerRef.contains(event.target as Node)) { 186 closeMenu(); 187 } 188 } 189 190 function onResize() { 191 closeMenu(); 192 } 193 194 onMount(() => { 195 document.addEventListener("mousedown", onOutsideClick); 196 window.addEventListener("resize", onResize); 197 198 onCleanup(() => { 199 document.removeEventListener("mousedown", onOutsideClick); 200 window.removeEventListener("resize", onResize); 201 }); 202 }); 203 204 function handleToggle() { 205 if (!open() && buttonRef) { 206 const rect = buttonRef.getBoundingClientRect(); 207 const preferredLeft = rect.left; 208 const maxLeft = window.innerWidth - 176; 209 setMenuPos({ top: rect.bottom + 8, left: Math.max(8, Math.min(preferredLeft, maxLeft)) }); 210 } 211 212 setOpen((value) => !value); 213 } 214 215 async function handleSelect(theme: Theme) { 216 await props.onChangeTheme(theme); 217 closeMenu(); 218 } 219 220 return ( 221 <div 222 ref={el => (containerRef = el)} 223 class="relative flex" 224 classList={{ "w-full": !compact(), "justify-center": compact() }}> 225 <button 226 ref={el => (buttonRef = el)} 227 type="button" 228 aria-label="Theme menu" 229 aria-expanded={open()} 230 aria-haspopup="menu" 231 onClick={handleToggle} 232 class="relative flex h-11 shrink-0 items-center rounded-lg border-0 bg-transparent text-on-surface-variant no-underline transition-[width,padding,transform,background-color,color] duration-200 ease-out motion-reduce:transition-none hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface" 233 classList={{ 234 "w-[2.75rem] justify-center gap-0": compact(), 235 "w-full justify-start gap-2.5 px-3": !compact(), 236 "bg-surface-container text-primary": open(), 237 }}> 238 <Icon iconClass={iconClassForTheme(props.currentTheme)} class="shrink-0 text-[1.25rem]" /> 239 <span 240 class="overflow-hidden whitespace-nowrap text-sm font-medium leading-none transition-[max-width,opacity] duration-200 ease-out motion-reduce:transition-none" 241 classList={{ "max-w-40 opacity-100": !compact(), "max-w-0 opacity-0": compact() }} 242 aria-hidden={compact() ? "true" : undefined}> 243 Theme 244 </span> 245 </button> 246 247 <Show when={open()}> 248 <div 249 role="menu" 250 style={{ position: "fixed", top: `${menuPos().top}px`, left: `${menuPos().left}px` }} 251 class="ui-overlay-card z-50 min-w-40 rounded-xl border ui-outline-subtle bg-surface-container p-1.5 backdrop-blur-[20px]"> 252 <For each={RAIL_THEME_OPTIONS}> 253 {(option) => ( 254 <button 255 type="button" 256 role="menuitemradio" 257 aria-checked={props.currentTheme === option.value} 258 class="flex w-full items-center gap-2 rounded-lg border-0 bg-transparent px-3 py-2 text-left text-sm transition duration-150" 259 classList={{ 260 "bg-surface-bright text-primary": props.currentTheme === option.value, 261 "text-on-surface-variant hover:bg-surface-bright hover:text-on-surface": 262 props.currentTheme !== option.value, 263 }} 264 onClick={() => void handleSelect(option.value)}> 265 <Icon iconClass={option.iconClass} /> 266 <span>{option.label}</span> 267 </button> 268 )} 269 </For> 270 </div> 271 </Show> 272 </div> 273 ); 274} 275 276function RailSecondaryActions(props: { collapsed: boolean }) { 277 return ( 278 <div class="grid gap-1 max-lg:hidden max-lg:col-span-full max-lg:grid-flow-col max-lg:justify-start"> 279 <RailActionButton 280 compact={props.collapsed} 281 icon="heart" 282 label="Support" 283 onClick={() => void openUrl("https://github.com/sponsors/desertthunder")} /> 284 </div> 285 ); 286} 287 288export function AppRail() { 289 const preferences = useAppPreferences(); 290 const session = useAppSession(); 291 const shell = useAppShellUi(); 292 const history = useNavigationHistory(); 293 const currentTheme = createMemo(() => normalizeThemeSetting(preferences.settings?.theme)); 294 295 async function handleChangeTheme(theme: Theme) { 296 await preferences.updateSetting("theme", theme); 297 } 298 299 return ( 300 <aside 301 class="flex min-h-screen min-w-0 flex-col gap-6 overflow-visible bg-surface-container-lowest px-6 pb-6 pt-6 transition-[padding,gap] duration-220 ease-out motion-reduce:transition-none max-lg:grid max-lg:min-h-0 max-lg:grid-cols-[auto_minmax(0,1fr)_auto_auto_auto] max-lg:items-center max-lg:gap-x-4 max-lg:gap-y-3 max-lg:p-4" 302 classList={{ 303 "items-center px-4": shell.railCondensed && !shell.narrowViewport, 304 "gap-5": shell.railCondensed && !shell.narrowViewport, 305 }} 306 aria-label="Primary navigation"> 307 <RailHeader collapsed={shell.railCondensed} onToggleCollapse={shell.toggleRailCollapsed} /> 308 <RailNavigation 309 collapsed={shell.railCondensed} 310 hasSession={session.hasSession} 311 narrow={shell.narrowViewport} 312 unreadNotifications={session.unreadNotifications} /> 313 <div class="mt-auto grid gap-2 max-lg:contents"> 314 <Show when={!shell.railCondensed}> 315 <RailSecondaryActions collapsed={shell.railCondensed} /> 316 </Show> 317 <Show when={shell.showThemeRailControl}> 318 <RailThemeMenu 319 collapsed={shell.railCondensed} 320 currentTheme={currentTheme()} 321 onChangeTheme={handleChangeTheme} /> 322 </Show> 323 <div class="flex items-center gap-1" classList={{ "w-full justify-center": shell.railCondensed }}> 324 <HistoryControls 325 canGoBack={history.canGoBack()} 326 canGoForward={history.canGoForward()} 327 onGoBack={history.goBack} 328 onGoForward={history.goForward} /> 329 </div> 330 <AccountSwitcher /> 331 </div> 332 </aside> 333 ); 334}