BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import type { ColumnKind } from "$/lib/api/types/columns";
2import type { SearchMode } from "$/lib/api/types/search";
3import { createEffect, createSignal, For, Match, onCleanup, Show, splitProps, Switch } from "solid-js";
4import { Portal } from "solid-js/web";
5import { Motion, Presence } from "solid-motionone";
6import { Icon, type IconKind } from "../shared/Icon";
7import { DiagnosticsPicker, ExplorerPicker, FeedPicker, MessagesPicker } from "./ColumnPicker";
8import { ProfilePicker } from "./ColumnPicker/ProfileColumnPicker";
9import { SearchPicker } from "./ColumnPicker/SearchPicker";
10import type { FeedPickerSelection, ProfileSelection } from "./types";
11
12type AddColumnPanelProps = { onAdd: (kind: ColumnKind, config: string) => void; onClose: () => void; open: boolean };
13
14type PanelTab = ColumnKind;
15
16type TabProps = { icon: IconKind; id: PanelTab; label: string };
17
18type PanelSubmissionHandlers = {
19 onDiagnosticsSubmit: (did: string) => void;
20 onExplorerSubmit: (uri: string) => void;
21 onFeedSelect: (selection: FeedPickerSelection) => void;
22 onMessagesSubmit: () => void;
23 onProfileSubmit: (selection: ProfileSelection) => void;
24 onSearchSubmit: (query: string, mode: SearchMode) => void;
25};
26
27function PanelContent(props: { handlers: PanelSubmissionHandlers; tab: PanelTab }) {
28 return (
29 <div class="min-h-0 flex-1 overflow-y-auto px-4 pb-6">
30 <Switch>
31 <Match when={props.tab === "feed"}>
32 <FeedPicker onSelect={props.handlers.onFeedSelect} />
33 </Match>
34
35 <Match when={props.tab === "explorer"}>
36 <ExplorerPicker onSubmit={props.handlers.onExplorerSubmit} />
37 </Match>
38
39 <Match when={props.tab === "diagnostics"}>
40 <DiagnosticsPicker onSubmit={props.handlers.onDiagnosticsSubmit} />
41 </Match>
42
43 <Match when={props.tab === "messages"}>
44 <MessagesPicker onSubmit={props.handlers.onMessagesSubmit} />
45 </Match>
46
47 <Match when={props.tab === "search"}>
48 <SearchPicker onSubmit={props.handlers.onSearchSubmit} />
49 </Match>
50
51 <Match when={props.tab === "profile"}>
52 <ProfilePicker onSubmit={props.handlers.onProfileSubmit} />
53 </Match>
54 </Switch>
55 </div>
56 );
57}
58
59function AddColumnPanelHeader(props: { onClose: () => void }) {
60 return (
61 <div class="flex shrink-0 items-center justify-between gap-3 px-5 py-4 shadow-(--inset-shadow)">
62 <div>
63 <p id="add-column-panel-title" class="m-0 text-sm font-semibold text-on-surface">Add column</p>
64 <p class="m-0 mt-1 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Choose a view</p>
65 </div>
66 <button
67 type="button"
68 class="flex h-8 w-8 items-center justify-center rounded-full border-0 bg-transparent text-on-surface-variant transition duration-150 hover:bg-surface-bright hover:text-on-surface"
69 aria-label="Close panel"
70 onClick={() => props.onClose()}>
71 <Icon kind="close" />
72 </button>
73 </div>
74 );
75}
76
77type AddColumnPanelTabsProps = { activeTab: PanelTab; onTabChange: (tab: PanelTab) => void; tabs: TabProps[] };
78
79function AddColumnPanelTabs(props: AddColumnPanelTabsProps) {
80 return (
81 <div class="grid shrink-0 grid-cols-2 gap-1 px-5 py-3">
82 <For each={props.tabs}>
83 {(tab) => (
84 <button
85 type="button"
86 class="flex items-center justify-center gap-1.5 rounded-lg border-0 px-3 py-2 text-xs font-medium transition duration-150"
87 classList={{
88 "bg-primary/15 text-primary": props.activeTab === tab.id,
89 "bg-transparent text-on-surface-variant hover:bg-surface-bright hover:text-on-surface":
90 props.activeTab !== tab.id,
91 }}
92 onClick={() => props.onTabChange(tab.id)}>
93 <Icon kind={tab.icon} />
94 {tab.label}
95 </button>
96 )}
97 </For>
98 </div>
99 );
100}
101
102type AddColumnPanelFrame = {
103 activeTab: PanelTab;
104 onClose: () => void;
105 onTabChange: (tab: PanelTab) => void;
106 tabs: TabProps[];
107};
108
109type AddColumnPanelBodyProps = { frame: AddColumnPanelFrame; handlers: PanelSubmissionHandlers };
110
111function AddColumnPanelBody(props: AddColumnPanelBodyProps) {
112 const [frameProps, contentProps] = splitProps(props, ["frame"], ["handlers"]);
113
114 return (
115 <Motion.aside
116 role="dialog"
117 aria-modal
118 aria-labelledby="add-column-panel-title"
119 class="ui-overlay-card relative z-10 flex h-full w-full max-w-88 flex-col bg-surface-container-highest backdrop-blur-[20px]"
120 initial={{ opacity: 0, x: 32 }}
121 animate={{ opacity: 1, x: 0 }}
122 exit={{ opacity: 0, x: 40 }}
123 transition={{ duration: 0.22, easing: [0.32, 0.72, 0, 1] }}>
124 <AddColumnPanelHeader onClose={frameProps.frame.onClose} />
125 <AddColumnPanelTabs
126 activeTab={frameProps.frame.activeTab}
127 tabs={frameProps.frame.tabs}
128 onTabChange={frameProps.frame.onTabChange} />
129 <PanelContent tab={frameProps.frame.activeTab} handlers={contentProps.handlers} />
130 </Motion.aside>
131 );
132}
133
134export function AddColumnPanel(props: AddColumnPanelProps) {
135 const [panelState, panelActions] = splitProps(props, ["open"], ["onAdd", "onClose"]);
136 const [activeTab, setActiveTab] = createSignal<PanelTab>("feed");
137
138 function handleFeedSelect(selection: FeedPickerSelection) {
139 const config = JSON.stringify({
140 feedType: selection.feed.type,
141 feedUri: selection.feed.value,
142 title: selection.title,
143 });
144 panelActions.onAdd("feed", config);
145 }
146
147 function handleExplorerSubmit(uri: string) {
148 const config = JSON.stringify({ targetUri: uri });
149 panelActions.onAdd("explorer", config);
150 }
151
152 function handleDiagnosticsSubmit(did: string) {
153 const config = JSON.stringify({ did });
154 panelActions.onAdd("diagnostics", config);
155 }
156
157 function handleMessagesSubmit() {
158 panelActions.onAdd("messages", JSON.stringify({}));
159 }
160
161 function handleSearchSubmit(query: string, mode: SearchMode) {
162 panelActions.onAdd("search", JSON.stringify({ mode, query }));
163 }
164
165 function handleProfileSubmit(selection: ProfileSelection) {
166 panelActions.onAdd("profile", JSON.stringify(selection));
167 }
168
169 const tabs: TabProps[] = [
170 { icon: "rss", id: "feed", label: "Feed" },
171 { icon: "compass", id: "explorer", label: "Explorer" },
172 { icon: "stethoscope", id: "diagnostics", label: "Diagnostics" },
173 { icon: "messages", id: "messages", label: "DMs" },
174 { icon: "search", id: "search", label: "Search" },
175 { icon: "user", id: "profile", label: "Profile" },
176 ];
177
178 function handleKeyDown(event: KeyboardEvent) {
179 if (event.key === "Escape") {
180 panelActions.onClose();
181 }
182 }
183
184 createEffect(() => {
185 if (!panelState.open) {
186 setActiveTab("feed");
187 return;
188 }
189
190 globalThis.addEventListener("keydown", handleKeyDown);
191 onCleanup(() => globalThis.removeEventListener("keydown", handleKeyDown));
192 });
193
194 return (
195 <Presence exitBeforeEnter>
196 <Show when={panelState.open}>
197 <Portal>
198 <div class="fixed inset-0 z-50 flex justify-end">
199 <Motion.div
200 class="ui-scrim absolute inset-0 backdrop-blur-[20px]"
201 initial={{ opacity: 0 }}
202 animate={{ opacity: 1 }}
203 exit={{ opacity: 0 }}
204 transition={{ duration: 0.15 }}
205 onClick={() => panelActions.onClose()} />
206
207 <AddColumnPanelBody
208 frame={{ activeTab: activeTab(), tabs, onClose: panelActions.onClose, onTabChange: setActiveTab }}
209 handlers={{
210 onDiagnosticsSubmit: handleDiagnosticsSubmit,
211 onExplorerSubmit: handleExplorerSubmit,
212 onFeedSelect: handleFeedSelect,
213 onMessagesSubmit: handleMessagesSubmit,
214 onProfileSubmit: handleProfileSubmit,
215 onSearchSubmit: handleSearchSubmit,
216 }} />
217 </div>
218 </Portal>
219 </Show>
220 </Presence>
221 );
222}