BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { useThreadOverlayNavigation } from "$/components/posts/hooks/useThreadOverlayNavigation";
2import { useAppSession } from "$/contexts/app-session";
3import { addColumn, getColumns, removeColumn, reorderColumns, updateColumn } from "$/lib/api/columns";
4import { FeedController } from "$/lib/api/feeds";
5import type { Column, ColumnKind, ColumnWidth } from "$/lib/api/types/columns";
6import { getFeedName } from "$/lib/feeds";
7import type { FeedGeneratorView } from "$/lib/types";
8import * as logger from "@tauri-apps/plugin-log";
9import { createEffect, For, onCleanup, onMount, Show } from "solid-js";
10import { createStore, produce } from "solid-js/store";
11import { Motion } from "solid-motionone";
12import { ActionIcon, Icon, LoadingIcon } from "../shared/Icon";
13import { AddColumnPanel } from "./AddColumnPanel";
14import { DeckColumn } from "./DeckColumn";
15import { parseFeedConfig, resolveFeedColumn } from "./helpers";
16import { type ResolvedFeedColumn } from "./types";
17
18type DeckState = {
19 addPanelOpen: boolean;
20 columns: Column[];
21 dragOverId: string | null;
22 error: string | null;
23 feedColumns: Record<string, ResolvedFeedColumn>;
24 loading: boolean;
25};
26
27function DeckToolbar(props: { columnCount: number; onAdd: () => void }) {
28 return (
29 <div class="flex shrink-0 items-center justify-between gap-4 pb-5">
30 <div class="min-w-0">
31 <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Deck</p>
32 <p class="m-0 mt-0.5 text-xs uppercase tracking-[0.12em] text-on-surface-variant">
33 {props.columnCount === 0 ? "No columns" : `${props.columnCount} column${props.columnCount === 1 ? "" : "s"}`}
34 </p>
35 </div>
36 <button
37 type="button"
38 class="inline-flex h-11 items-center gap-2 rounded-full border-0 bg-surface-container-high px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright"
39 aria-label="Add column (Ctrl+Shift+N)"
40 title="Add column (Ctrl+Shift+N)"
41 onClick={() => props.onAdd()}>
42 <ActionIcon kind="add" />
43 Add column
44 </button>
45 </div>
46 );
47}
48
49function EmptyDeck(props: { onAdd: () => void }) {
50 return (
51 <div class="flex h-full min-h-104 flex-col items-center justify-center gap-4 rounded-[1.75rem] bg-surface-container px-6 text-center shadow-(--inset-shadow)">
52 <Icon kind="deck" class="text-[1.75rem] text-on-surface-variant opacity-30" />
53 <div>
54 <p class="m-0 text-sm font-medium text-on-surface">No columns yet</p>
55 <p class="m-0 mt-1 text-xs text-on-surface-variant">
56 Add a feed, explorer, or diagnostics column to get started.
57 </p>
58 </div>
59 <button
60 type="button"
61 class="inline-flex h-9 items-center gap-2 rounded-full border-0 bg-primary/15 px-4 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25"
62 onClick={() => props.onAdd()}>
63 <ActionIcon kind="add" />
64 Add first column
65 </button>
66 </div>
67 );
68}
69
70function ColumnList(
71 props: {
72 columns: Column[];
73 dragOverId: string | null;
74 feedColumns: Record<string, ResolvedFeedColumn>;
75 onClose: (id: string) => void;
76 onDragEnd: () => void;
77 onDragOver: (id: string) => void;
78 onDragStart: (id: string) => void;
79 onDrop: (targetId: string) => void;
80 onMoveLeft: (id: string) => void;
81 onMoveRight: (id: string) => void;
82 onOpenThread: (uri: string) => void;
83 onWidthChange: (id: string, width: ColumnWidth) => void;
84 },
85) {
86 return (
87 <div class="flex h-full min-h-0 items-stretch gap-4 pb-3">
88 <For each={props.columns}>
89 {(column) => (
90 <Motion.div
91 class="flex h-full shrink-0"
92 initial={{ opacity: 0, scale: 0.95 }}
93 animate={{ opacity: 1, scale: 1 }}
94 transition={{ duration: 0.18, easing: [0.34, 1.56, 0.64, 1] }}>
95 <DeckColumn
96 column={column}
97 feedColumn={props.feedColumns[column.id]}
98 isDragOver={props.dragOverId === column.id}
99 onClose={props.onClose}
100 onDragEnd={props.onDragEnd}
101 onDragOver={props.onDragOver}
102 onDragStart={props.onDragStart}
103 onDrop={props.onDrop}
104 onMoveLeft={props.onMoveLeft}
105 onMoveRight={props.onMoveRight}
106 onOpenThread={props.onOpenThread}
107 onWidthChange={props.onWidthChange} />
108 </Motion.div>
109 )}
110 </For>
111 </div>
112 );
113}
114
115function createDeckKeyboardHandler(onAddColumn: () => void, onCloseLastColumn: () => void) {
116 return (e: KeyboardEvent) => {
117 if (!e.ctrlKey && !e.metaKey) return;
118 if (!e.shiftKey) return;
119
120 if (e.key === "N" || e.key === "n") {
121 e.preventDefault();
122 onAddColumn();
123 } else if (e.key === "W" || e.key === "w") {
124 e.preventDefault();
125 onCloseLastColumn();
126 }
127 };
128}
129
130export function DeckWorkspace() {
131 const session = useAppSession();
132 const threadOverlay = useThreadOverlayNavigation();
133 let feedColumnRequest = 0;
134 let draggingColumnId: string | null = null;
135
136 const [state, setState] = createStore<DeckState>({
137 addPanelOpen: false,
138 columns: [],
139 dragOverId: null,
140 error: null,
141 feedColumns: {},
142 loading: true,
143 });
144
145 const activeDid = () => session.activeDid;
146
147 async function loadColumns() {
148 const did = activeDid();
149 if (!did) return;
150 try {
151 const cols = await getColumns(did);
152 setState("columns", cols);
153 setState("error", null);
154 void hydrateFeedColumns(cols);
155 } catch (err) {
156 const message = err instanceof Error ? err.message : String(err);
157 logger.error(`Failed to load deck columns: ${message}`);
158 setState("error", message);
159 } finally {
160 setState("loading", false);
161 }
162 }
163
164 async function hydrateFeedColumns(columns: Column[]) {
165 const currentRequest = ++feedColumnRequest;
166 const parsedFeedColumns = columns.flatMap((column) => {
167 if (column.kind !== "feed") {
168 return [];
169 }
170
171 const config = parseFeedConfig(column.config);
172 return config ? [{ columnId: column.id, config }] : [];
173 });
174
175 if (parsedFeedColumns.length === 0) {
176 setState("feedColumns", {});
177 return;
178 }
179
180 setState(
181 "feedColumns",
182 Object.fromEntries(parsedFeedColumns.map(({ columnId, config }) => [columnId, resolveFeedColumn(config)])),
183 );
184
185 try {
186 const preferences = await FeedController.getPreferences();
187 const savedFeedTitles = Object.fromEntries(
188 preferences.savedFeeds.map((feed) => [feed.value, getFeedName(feed, void 0)]),
189 );
190
191 const generatorUris = [
192 ...new Set(
193 parsedFeedColumns.filter(({ config }) => config.feedType === "feed").map(({ config }) =>
194 config.feedUri
195 ),
196 ),
197 ];
198 let generators: Record<string, FeedGeneratorView> = {};
199
200 if (generatorUris.length > 0) {
201 const hydrated = await FeedController.getFeedGenerators(generatorUris);
202 generators = Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator]));
203 }
204
205 const nextFeedColumns = Object.fromEntries(
206 parsedFeedColumns.map((
207 { columnId, config },
208 ) => [
209 columnId,
210 resolveFeedColumn(config, {
211 generator: generators[config.feedUri],
212 savedFeedTitle: savedFeedTitles[config.feedUri],
213 }),
214 ]),
215 );
216
217 if (currentRequest !== feedColumnRequest) {
218 return;
219 }
220
221 setState("feedColumns", nextFeedColumns);
222 } catch (err) {
223 logger.warn(`Failed to hydrate deck feed columns: ${String(err)}`);
224 }
225 }
226
227 async function handleAdd(kind: ColumnKind, config: string) {
228 const did = activeDid();
229 if (!did) return;
230 try {
231 const col = await addColumn(did, kind, config);
232 const nextColumns = [...state.columns, col];
233 setState("columns", nextColumns);
234 setState("addPanelOpen", false);
235 if (kind === "feed") {
236 void hydrateFeedColumns(nextColumns);
237 }
238 } catch (err) {
239 logger.error(`Failed to add column: ${String(err)}`);
240 }
241 }
242
243 async function handleClose(id: string) {
244 try {
245 await removeColumn(id);
246 const nextColumns = state.columns.filter((column) => column.id !== id);
247 setState("columns", nextColumns);
248 void hydrateFeedColumns(nextColumns);
249 } catch (err) {
250 logger.error(`Failed to remove column: ${String(err)}`);
251 }
252 }
253
254 async function handleWidthChange(id: string, width: ColumnWidth) {
255 try {
256 const updated = await updateColumn(id, { width });
257 setState("columns", (prev) => prev.map((c) => (c.id === id ? updated : c)));
258 } catch (err) {
259 logger.error(`Failed to update column width: ${String(err)}`);
260 }
261 }
262
263 async function handleMoveLeft(id: string) {
264 const cols = state.columns;
265 const idx = cols.findIndex((c) => c.id === id);
266 if (idx === -1 || idx === 0) return;
267
268 const newOrder = cols.map((c) => c.id);
269 newOrder.splice(idx, 1);
270 newOrder.splice(idx - 1, 0, id);
271
272 try {
273 await reorderColumns(newOrder);
274 setState(
275 "columns",
276 produce((draft) => {
277 const item = draft.splice(idx, 1)[0];
278 if (item) draft.splice(idx - 1, 0, item);
279 }),
280 );
281 } catch (err) {
282 logger.error(`Failed to reorder columns: ${String(err)}`);
283 }
284 }
285
286 async function handleMoveRight(id: string) {
287 const cols = state.columns;
288 const idx = cols.findIndex((c) => c.id === id);
289 if (idx === -1 || idx >= cols.length - 1) return;
290
291 const newOrder = cols.map((c) => c.id);
292 newOrder.splice(idx, 1);
293 newOrder.splice(idx + 1, 0, id);
294
295 try {
296 await reorderColumns(newOrder);
297 setState(
298 "columns",
299 produce((draft) => {
300 const item = draft.splice(idx, 1)[0];
301 if (item) draft.splice(idx + 1, 0, item);
302 }),
303 );
304 } catch (err) {
305 logger.error(`Failed to reorder columns: ${String(err)}`);
306 }
307 }
308
309 function handleDragStart(id: string) {
310 draggingColumnId = id;
311 }
312
313 function handleDragEnd() {
314 draggingColumnId = null;
315 setState("dragOverId", null);
316 }
317
318 function handleDragOver(id: string) {
319 if (draggingColumnId && draggingColumnId !== id) {
320 setState("dragOverId", id);
321 }
322 }
323
324 async function handleDrop(targetId: string) {
325 const sourceId = draggingColumnId;
326 draggingColumnId = null;
327 setState("dragOverId", null);
328
329 if (!sourceId || sourceId === targetId) return;
330
331 const cols = state.columns;
332 const fromIdx = cols.findIndex((c) => c.id === sourceId);
333 const toIdx = cols.findIndex((c) => c.id === targetId);
334 if (fromIdx === -1 || toIdx === -1) return;
335
336 const newOrder = cols.map((c) => c.id);
337 newOrder.splice(fromIdx, 1);
338 newOrder.splice(toIdx, 0, sourceId);
339
340 try {
341 await reorderColumns(newOrder);
342 setState(
343 "columns",
344 produce((draft) => {
345 const item = draft.splice(fromIdx, 1)[0];
346 if (item) draft.splice(toIdx, 0, item);
347 }),
348 );
349 } catch (err) {
350 logger.error(`Failed to reorder columns via drag: ${String(err)}`);
351 }
352 }
353
354 function handleOpenThread(uri: string) {
355 void threadOverlay.openThread(uri);
356 }
357
358 createEffect(() => {
359 const handler = createDeckKeyboardHandler(() => setState("addPanelOpen", true), () => {
360 const last = state.columns.at(-1);
361 if (last) void handleClose(last.id);
362 });
363 globalThis.addEventListener("keydown", handler);
364 onCleanup(() => globalThis.removeEventListener("keydown", handler));
365 });
366
367 onMount(() => {
368 void loadColumns();
369 });
370
371 return (
372 <div class="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden px-6 py-5 max-md:px-4 max-md:py-4 max-sm:px-3 max-sm:py-3">
373 <DeckToolbar columnCount={state.columns.length} onAdd={() => setState("addPanelOpen", true)} />
374
375 <div class="min-h-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-contain">
376 <Show when={state.loading}>
377 <div class="flex h-full min-h-80 items-center justify-center">
378 <LoadingIcon isLoading class="text-2xl text-on-surface-variant" />
379 </div>
380 </Show>
381
382 <Show when={!state.loading && state.error}>
383 <div class="rounded-2xl bg-[rgba(138,31,31,0.2)] p-4 text-sm text-error shadow-[inset_0_0_0_1px_rgba(255,128,128,0.2)]">
384 {state.error}
385 </div>
386 </Show>
387
388 <Show when={!state.loading && !state.error && state.columns.length === 0}>
389 <EmptyDeck onAdd={() => setState("addPanelOpen", true)} />
390 </Show>
391
392 <Show when={!state.loading && state.columns.length > 0}>
393 <ColumnList
394 columns={state.columns}
395 dragOverId={state.dragOverId}
396 feedColumns={state.feedColumns}
397 onClose={handleClose}
398 onDragEnd={handleDragEnd}
399 onDragOver={handleDragOver}
400 onDragStart={handleDragStart}
401 onDrop={handleDrop}
402 onMoveLeft={handleMoveLeft}
403 onMoveRight={handleMoveRight}
404 onOpenThread={handleOpenThread}
405 onWidthChange={handleWidthChange} />
406 </Show>
407 </div>
408
409 <AddColumnPanel open={state.addPanelOpen} onAdd={handleAdd} onClose={() => setState("addPanelOpen", false)} />
410 </div>
411 );
412}