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