BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
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}