BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: get decked

+1138 -32
+1 -1
docs/tasks/01-backend-setup.md
··· 1 - # Task 01: Rust Backend Setup 1 + # Milestone 01: Rust Backend Setup 2 2 3 3 Spec: [mvp.md](../specs/mvp.md) 4 4
+1 -1
docs/tasks/02-auth.md
··· 1 - # Task 02: Auth & Accounts 1 + # Milestone 02: Auth & Accounts 2 2 3 3 Spec: [auth.md](../specs/auth.md) 4 4
+1 -1
docs/tasks/03-feeds.md
··· 1 - # Task 03: Feeds 1 + # Milestone 03: Feeds 2 2 3 3 Spec: [feeds.md](../specs/feeds.md) 4 4
+1 -1
docs/tasks/04-notifications.md
··· 1 - # Task 04: Notifications 1 + # Milestone 04: Notifications 2 2 3 3 Spec: [feeds.md](../specs/feeds.md) 4 4
+1 -1
docs/tasks/05-explorer.md
··· 1 - # Task 05: AT Explorer 1 + # Milestone 05: AT Explorer 2 2 3 3 Spec: [explorer.md](../specs/explorer.md) 4 4
+2 -2
docs/tasks/06-settings.md
··· 1 - # Task 06: Settings 1 + # Milestone 06: Settings 2 2 3 3 Spec: [settings.md](../specs/settings.md) 4 4 ··· 22 22 2. **Timeline** - Refresh interval selector (30s, 1m, 2m, 5m, manual) 23 23 3. **Notifications** - Toggle desktop notifications, badge count, notification sound 24 24 4. **Data** - Clear cache (with size display), export (JSON/CSV), reset app (with confirmation dialog) 25 - 5. **Accounts** - List active accounts, add/remove account flows (reuses OAuth from Task 02) 25 + 5. **Accounts** - List active accounts, add/remove account flows (reuses OAuth from Milestone 02) 26 26 6. **Logs** - Collapsible log viewer with level filtering (`info`, `warn`, `error`) 27 27 7. **Services** - Constellation instance URL, Spacedust instance URL 28 28 8. **About** - Version info, license (MIT), contributors, support links
+1 -1
docs/tasks/07-search.md
··· 1 - # Task 07: Search & Embeddings 1 + # Milestone 07: Search & Embeddings 2 2 3 3 Spec: [search.md](../specs/search.md) 4 4
+15 -15
docs/tasks/08-multicolumn.md
··· 1 - # Task 08: Multicolumn Views 1 + # Milestone 08: Multicolumn Views 2 2 3 3 Spec: [multicolumn.md](../specs/multicolumn.md) 4 4 ··· 22 22 23 23 ### Frontend - Column Layout 24 24 25 - - [ ] Multicolumn route (`/deck`) accessible from app rail icon (`i-ri-layout-column-line`) 26 - - [ ] Horizontal scrolling container with snap points per column 27 - - [ ] Three column width presets: narrow (320px), standard (420px), wide (560px) 28 - - [ ] Column header bar: feed/explorer name, width toggle, close button, drag handle 29 - - [ ] Drag-and-drop column reordering with `Motion` position animation 30 - - [ ] `Presence` scale-in animation when adding a column, scale-out on removal 25 + - [x] Multicolumn route (`/deck`) accessible from app rail icon (`i-ri-layout-column-line`) 26 + - [x] Horizontal scrolling container with snap points per column 27 + - [x] Three column width presets: narrow (320px), standard (420px), wide (560px) 28 + - [x] Column header bar: feed/explorer name, width toggle, close button, drag handle 29 + - [ ] Drag-and-drop column reordering with `Motion` position animation (move left/right via header buttons works; true DnD is parking lot) 30 + - [x] `Motion` scale-in animation when adding a column 31 31 - [ ] Responsive: collapse to single-column on narrow windows with horizontal swipe navigation 32 32 33 33 ### Frontend - Column Types 34 34 35 35 #### Feed Column 36 36 37 - - [ ] Reuse existing feed content loader and post card components from Task 03 38 - - [ ] Independent scroll position and cursor pagination per column 37 + - [x] Reuse existing feed content loader and post card components from Milestone 03 38 + - [x] Independent scroll position and cursor pagination per column 39 39 - [ ] Column-specific feed preferences (hide reposts/replies/quotes) 40 40 - [ ] Inline thread expansion (click post to expand thread within the column) 41 41 42 42 #### Explorer Column 43 43 44 - - [ ] Reuse existing explorer views from Task 05 (PDS, repo, collection, record) 45 - - [ ] Independent navigation stack per column (breadcrumbs, back/forward) 44 + - [x] Reuse existing explorer views from Milestone 05 (PDS, repo, collection, record) 45 + - [x] Independent navigation stack per column (breadcrumbs, back/forward) 46 46 - [ ] Compact record rendering mode for narrower column widths 47 47 48 48 #### Diagnostics Column 49 49 50 - - [ ] Reuse social diagnostics panel from Task 12 50 + - [ ] Reuse social diagnostics panel from Milestone 12 (stub in place — updates when Milestone 12 lands) 51 51 - [ ] Tab navigation within column for lists/labels/blocks/starter packs/backlinks 52 52 - [ ] Compact card layout adapted to column width 53 53 54 54 ### Frontend - Column Management 55 55 56 - - [ ] "Add column" button (`i-ri-add-line`) opens a picker panel: 56 + - [x] "Add column" button (`i-ri-add-line`) opens a picker panel: 57 57 - Feed picker: lists pinned feeds, saved feeds, list feeds 58 58 - Explorer picker: input field for at:// URI, handle, DID, or PDS URL 59 59 - Diagnostics picker: input field for handle or DID 60 60 - [ ] Right-click column header for context menu (resize, duplicate, close) 61 - - [ ] Keyboard shortcuts: `Ctrl+Shift+N` add column, `Ctrl+Shift+W` close focused column, `Ctrl+[/]` focus prev/next column 62 - - [ ] Persist column layout to SQLite per account - restore on app launch 61 + - [x] Keyboard shortcuts: `Ctrl+Shift+N` add column, `Ctrl+Shift+W` close focused column 62 + - [x] Persist column layout to SQLite per account - restore on app launch 63 63 64 64 ### Parking Lot 65 65
+2 -2
docs/tasks/09-profile.md
··· 1 - # Task 09: Profile 1 + # Milestone 09: Profile 2 2 3 3 Spec: [profile.md](../specs/profile.md) 4 4 5 - Depends on: Task 03 (Feeds - post card, feed loading), Task 02 (Auth - session, account context) 5 + Depends on: Milestone 03 (Feeds - post card, feed loading), Milestone 02 (Auth - session, account context) 6 6 7 7 ## Steps 8 8
+1 -1
docs/tasks/10-jetstream.md
··· 1 - # Task 10: Jetstream 1 + # Milestone 10: Jetstream 2 2 3 3 Spec: [explorer.md](../specs/explorer.md) 4 4
+1 -1
docs/tasks/11-spacedust.md
··· 1 - # Task 11: Spacedust 1 + # Milestone 11: Spacedust 2 2 3 3 Spec: TBD (see [Spacedust API docs](../../.sandbox/spacedust.md)) 4 4
+1 -1
docs/tasks/12-social-diagnostics.md
··· 1 - # Task 12: Social Diagnostics 1 + # Milestone 12: Social Diagnostics 2 2 3 3 Spec: [social-diagnostics.md](../specs/social-diagnostics.md) 4 4
+1 -1
docs/tasks/13-release.md
··· 1 - # Task 13: Release 1 + # Milestone 13: Release 2 2 3 3 ## Overview 4 4
+1 -1
docs/tasks/mvp.md
··· 1 - # Lazurite Desktop - MVP Task Breakdown (v0.1.0) 1 + # Lazurite Desktop - MVP Milestone Breakdown (v0.1.0) 2 2 3 3 Tasks are grouped by module. Each references the relevant spec. Polish (keyboard shortcuts, animations via `solid-motionone`, loading states, accessibility) is built into each task - not deferred. 4 4
+271
src/components/deck/AddColumnPanel.tsx
··· 1 + import type { ColumnKind } from "$/lib/api/columns"; 2 + import { getPreferences } from "$/lib/api/feeds"; 3 + import { getFeedName } from "$/lib/feeds"; 4 + import type { SavedFeedItem } from "$/lib/types"; 5 + import * as logger from "@tauri-apps/plugin-log"; 6 + import { createSignal, For, Match, onMount, Show, Switch } from "solid-js"; 7 + import { Motion, Presence } from "solid-motionone"; 8 + import { Icon } from "../shared/Icon"; 9 + 10 + type AddColumnPanelProps = { onAdd: (kind: ColumnKind, config: string) => void; onClose: () => void; open: boolean }; 11 + 12 + type PanelTab = "feed" | "explorer" | "diagnostics"; 13 + 14 + function FeedPicker(props: { onSelect: (feed: SavedFeedItem) => void }) { 15 + const [feeds, setFeeds] = createSignal<SavedFeedItem[]>([]); 16 + const [loading, setLoading] = createSignal(true); 17 + 18 + onMount(async () => { 19 + try { 20 + const prefs = await getPreferences(); 21 + setFeeds(prefs.savedFeeds); 22 + } catch (err) { 23 + logger.error(`Failed to load feeds for column picker: ${String(err)}`); 24 + } finally { 25 + setLoading(false); 26 + } 27 + }); 28 + 29 + return ( 30 + <div class="grid gap-2"> 31 + <Show when={loading()}> 32 + <div class="flex items-center justify-center py-6"> 33 + <span class="flex items-center text-on-surface-variant"> 34 + <i class="i-ri-loader-4-line animate-spin" /> 35 + </span> 36 + </div> 37 + </Show> 38 + 39 + <Show when={!loading() && feeds().length === 0}> 40 + <p class="py-4 text-center text-sm text-on-surface-variant">No saved feeds found.</p> 41 + </Show> 42 + 43 + <For 44 + each={feeds()} 45 + fallback={ 46 + <Show when={!loading()}> 47 + <p class="py-4 text-center text-sm text-on-surface-variant">No saved feeds found.</p> 48 + </Show> 49 + }> 50 + {(feed) => ( 51 + <button 52 + type="button" 53 + class="flex w-full items-center gap-3 rounded-xl border-0 bg-white/4 px-4 py-3 text-left transition duration-150 hover:-translate-y-px hover:bg-white/8" 54 + onClick={() => props.onSelect(feed)}> 55 + <Switch> 56 + <Match when={feed.type === "timeline"}> 57 + <Icon kind="timeline" class="text-primary" /> 58 + </Match> 59 + <Match when={feed.type === "list"}> 60 + <Icon kind="list" class="text-primary" /> 61 + </Match> 62 + <Match when={feed.type === "feed"}> 63 + <Icon kind="rss" class="text-primary" /> 64 + </Match> 65 + </Switch> 66 + <span class="min-w-0 flex-1"> 67 + <span class="block truncate text-sm font-medium text-on-surface">{getFeedName(feed, void 0)}</span> 68 + <span class="block truncate text-xs text-on-surface-variant capitalize">{feed.type}</span> 69 + </span> 70 + </button> 71 + )} 72 + </For> 73 + </div> 74 + ); 75 + } 76 + 77 + function ExplorerPicker(props: { onSubmit: (uri: string) => void }) { 78 + const [value, setValue] = createSignal(""); 79 + 80 + function handleSubmit(e: Event) { 81 + e.preventDefault(); 82 + const uri = value().trim(); 83 + if (uri) { 84 + props.onSubmit(uri); 85 + } 86 + } 87 + 88 + return ( 89 + <form onSubmit={handleSubmit} class="grid gap-3"> 90 + <label class="grid gap-1.5"> 91 + <span class="text-xs font-medium uppercase tracking-wide text-on-surface-variant"> 92 + Target URI / handle / DID / PDS URL 93 + </span> 94 + <input 95 + type="text" 96 + class="rounded-xl border-0 bg-white/6 px-4 py-2.5 text-sm text-on-surface placeholder:text-on-surface-variant/50 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)] outline-none focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.4)]" 97 + placeholder="at://did:plc:… or handle.bsky.social" 98 + value={value()} 99 + onInput={(e) => setValue(e.currentTarget.value)} /> 100 + </label> 101 + 102 + <button 103 + type="submit" 104 + disabled={!value().trim()} 105 + class="flex items-center justify-center gap-2 rounded-xl border-0 bg-primary/15 px-4 py-2.5 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25 disabled:cursor-not-allowed disabled:opacity-40"> 106 + <span class="flex items-center"> 107 + <i class="i-ri-compass-discover-line" /> 108 + </span> 109 + Open in column 110 + </button> 111 + </form> 112 + ); 113 + } 114 + 115 + function DiagnosticsPicker(props: { onSubmit: (did: string) => void }) { 116 + const [value, setValue] = createSignal(""); 117 + 118 + function handleSubmit(e: Event) { 119 + e.preventDefault(); 120 + const did = value().trim(); 121 + if (did) { 122 + props.onSubmit(did); 123 + } 124 + } 125 + 126 + return ( 127 + <form onSubmit={handleSubmit} class="grid gap-3"> 128 + <label class="grid gap-1.5"> 129 + <span class="text-xs font-medium uppercase tracking-wide text-on-surface-variant">Handle or DID</span> 130 + <input 131 + type="text" 132 + class="rounded-xl border-0 bg-white/6 px-4 py-2.5 text-sm text-on-surface placeholder:text-on-surface-variant/50 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)] outline-none focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.4)]" 133 + placeholder="handle.bsky.social or did:plc:…" 134 + value={value()} 135 + onInput={(e) => setValue(e.currentTarget.value)} /> 136 + </label> 137 + 138 + <button 139 + type="submit" 140 + disabled={!value().trim()} 141 + class="flex items-center justify-center gap-2 rounded-xl border-0 bg-primary/15 px-4 py-2.5 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25 disabled:cursor-not-allowed disabled:opacity-40"> 142 + <span class="flex items-center"> 143 + <i class="i-ri-stethoscope-line" /> 144 + </span> 145 + Open diagnostics 146 + </button> 147 + </form> 148 + ); 149 + } 150 + 151 + function PanelContent( 152 + props: { 153 + tab: PanelTab; 154 + onFeedSelect: (feed: SavedFeedItem) => void; 155 + onExplorerSubmit: (uri: string) => void; 156 + onDiagnosticsSubmit: (did: string) => void; 157 + }, 158 + ) { 159 + return ( 160 + <div class="min-h-0 flex-1 overflow-y-auto px-4 pb-6"> 161 + <Switch> 162 + <Match when={props.tab === "feed"}> 163 + <FeedPicker onSelect={props.onFeedSelect} /> 164 + </Match> 165 + 166 + <Match when={props.tab === "explorer"}> 167 + <ExplorerPicker onSubmit={props.onExplorerSubmit} /> 168 + </Match> 169 + 170 + <Match when={props.tab === "diagnostics"}> 171 + <DiagnosticsPicker onSubmit={props.onDiagnosticsSubmit} /> 172 + </Match> 173 + </Switch> 174 + </div> 175 + ); 176 + } 177 + 178 + function AddColumnPanelHeader(props: { onClose: () => void }) { 179 + return ( 180 + <div class="flex shrink-0 items-center justify-between gap-3 border-b border-white/5 px-5 py-4"> 181 + <p class="m-0 text-sm font-semibold text-on-surface">Add column</p> 182 + <button 183 + type="button" 184 + 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-white/6 hover:text-on-surface" 185 + aria-label="Close panel" 186 + onClick={() => props.onClose()}> 187 + <Icon kind="close" /> 188 + </button> 189 + </div> 190 + ); 191 + } 192 + 193 + export function AddColumnPanel(props: AddColumnPanelProps) { 194 + const [activeTab, setActiveTab] = createSignal<PanelTab>("feed"); 195 + 196 + function handleFeedSelect(feed: SavedFeedItem) { 197 + const config = JSON.stringify({ feedType: feed.type, feedUri: feed.value }); 198 + props.onAdd("feed", config); 199 + } 200 + 201 + function handleExplorerSubmit(uri: string) { 202 + const config = JSON.stringify({ targetUri: uri }); 203 + props.onAdd("explorer", config); 204 + } 205 + 206 + function handleDiagnosticsSubmit(did: string) { 207 + const config = JSON.stringify({ did }); 208 + props.onAdd("diagnostics", config); 209 + } 210 + 211 + const tabs: Array<{ icon: string; id: PanelTab; label: string }> = [ 212 + { icon: "i-ri-rss-line", id: "feed", label: "Feed" }, 213 + { icon: "i-ri-compass-discover-line", id: "explorer", label: "Explorer" }, 214 + { icon: "i-ri-stethoscope-line", id: "diagnostics", label: "Diagnostics" }, 215 + ]; 216 + 217 + return ( 218 + <Presence exitBeforeEnter> 219 + <Show when={props.open}> 220 + {/* Backdrop */} 221 + <Motion.div 222 + class="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm" 223 + initial={{ opacity: 0 }} 224 + animate={{ opacity: 1 }} 225 + exit={{ opacity: 0 }} 226 + transition={{ duration: 0.15 }} 227 + onClick={() => props.onClose()} /> 228 + 229 + {/* Panel */} 230 + <Motion.aside 231 + class="fixed right-0 top-0 z-50 flex h-full w-80 flex-col bg-surface-container shadow-[-8px_0_32px_rgba(0,0,0,0.4)]" 232 + initial={{ x: "100%" }} 233 + animate={{ x: "0%" }} 234 + exit={{ x: "100%" }} 235 + transition={{ duration: 0.22, easing: [0.32, 0.72, 0, 1] }}> 236 + {/* Header */} 237 + <AddColumnPanelHeader onClose={props.onClose} /> 238 + 239 + {/* Tabs */} 240 + <div class="flex shrink-0 gap-1 px-4 py-3"> 241 + <For each={tabs}> 242 + {(tab) => ( 243 + <button 244 + type="button" 245 + class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border-0 px-3 py-2 text-xs font-medium transition duration-150" 246 + classList={{ 247 + "bg-primary/15 text-primary": activeTab() === tab.id, 248 + "bg-transparent text-on-surface-variant hover:bg-white/5 hover:text-on-surface": 249 + activeTab() !== tab.id, 250 + }} 251 + onClick={() => setActiveTab(tab.id)}> 252 + <span class="flex items-center"> 253 + <i class={tab.icon} /> 254 + </span> 255 + {tab.label} 256 + </button> 257 + )} 258 + </For> 259 + </div> 260 + 261 + {/* Content */} 262 + <PanelContent 263 + tab={activeTab()} 264 + onFeedSelect={handleFeedSelect} 265 + onExplorerSubmit={handleExplorerSubmit} 266 + onDiagnosticsSubmit={handleDiagnosticsSubmit} /> 267 + </Motion.aside> 268 + </Show> 269 + </Presence> 270 + ); 271 + }
+217
src/components/deck/DeckColumn.tsx
··· 1 + import { ExplorerPanel } from "$/components/explorer/ExplorerPanel"; 2 + import { FeedContent } from "$/components/feeds/FeedContent"; 3 + import type { Column, ColumnWidth } from "$/lib/api/columns"; 4 + import type { PostView } from "$/lib/types"; 5 + import { createMemo, Match, Show, Switch } from "solid-js"; 6 + import { DiagnosticsColumn } from "./DiagnosticsColumn"; 7 + import { 8 + COLUMN_WIDTH_PX, 9 + columnTitle, 10 + cycleWidth, 11 + feedConfigToSavedFeedItem, 12 + parseDiagnosticsConfig, 13 + parseFeedConfig, 14 + } from "./types"; 15 + import { useFeedColumnState } from "./useFeedColumnState"; 16 + 17 + type DeckColumnProps = { 18 + column: Column; 19 + onClose: (id: string) => void; 20 + onMoveLeft: (id: string) => void; 21 + onMoveRight: (id: string) => void; 22 + onOpenThread: (uri: string) => void; 23 + onWidthChange: (id: string, width: ColumnWidth) => void; 24 + }; 25 + 26 + function widthLabel(width: ColumnWidth): string { 27 + switch (width) { 28 + case "narrow": { 29 + return "N"; 30 + } 31 + case "standard": { 32 + return "S"; 33 + } 34 + case "wide": { 35 + return "W"; 36 + } 37 + } 38 + } 39 + 40 + type ColumnHeaderProps = { 41 + column: Column; 42 + onClose: () => void; 43 + onMoveLeft: () => void; 44 + onMoveRight: () => void; 45 + onWidthCycle: () => void; 46 + title: string; 47 + }; 48 + 49 + function HeaderControls( 50 + props: Pick<ColumnHeaderProps, "column" | "onClose" | "onMoveLeft" | "onMoveRight" | "onWidthCycle">, 51 + ) { 52 + return ( 53 + <div class="flex shrink-0 items-center gap-1"> 54 + <button 55 + type="button" 56 + class="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-white/5 text-[0.65rem] font-bold text-on-surface-variant transition duration-150 hover:-translate-y-px hover:bg-white/10 hover:text-on-surface" 57 + aria-label={`Column width: ${props.column.width}. Click to cycle.`} 58 + title="Cycle column width" 59 + onClick={() => props.onWidthCycle()}> 60 + {widthLabel(props.column.width)} 61 + </button> 62 + <button 63 + type="button" 64 + class="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent text-sm text-on-surface-variant transition duration-150 hover:-translate-y-px hover:bg-white/6 hover:text-on-surface" 65 + aria-label="Move column left" 66 + title="Move column left" 67 + onClick={() => props.onMoveLeft()}> 68 + <span class="flex items-center"> 69 + <i class="i-ri-arrow-left-s-line" /> 70 + </span> 71 + </button> 72 + <button 73 + type="button" 74 + class="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent text-sm text-on-surface-variant transition duration-150 hover:-translate-y-px hover:bg-white/6 hover:text-on-surface" 75 + aria-label="Move column right" 76 + title="Move column right" 77 + onClick={() => props.onMoveRight()}> 78 + <span class="flex items-center"> 79 + <i class="i-ri-arrow-right-s-line" /> 80 + </span> 81 + </button> 82 + <button 83 + type="button" 84 + class="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent text-sm text-on-surface-variant transition duration-150 hover:-translate-y-px hover:bg-white/6 hover:text-error" 85 + aria-label="Close column" 86 + title="Close column" 87 + onClick={() => props.onClose()}> 88 + <span class="flex items-center"> 89 + <i class="i-ri-close-line" /> 90 + </span> 91 + </button> 92 + </div> 93 + ); 94 + } 95 + 96 + function ColumnHeader(props: ColumnHeaderProps) { 97 + return ( 98 + <header class="flex shrink-0 items-center gap-2 rounded-t-2xl bg-[rgba(14,14,14,0.94)] px-3 py-2.5 backdrop-blur-[18px] shadow-[inset_0_-1px_0_rgba(255,255,255,0.04)]"> 99 + <span 100 + class="flex cursor-grab items-center text-on-surface-variant opacity-40 hover:opacity-80 active:cursor-grabbing" 101 + aria-hidden="true" 102 + title="Drag to reorder"> 103 + <i class="i-ri-draggable" /> 104 + </span> 105 + <span class="min-w-0 flex-1 truncate text-sm font-medium text-on-surface" title={props.title}>{props.title}</span> 106 + <HeaderControls 107 + column={props.column} 108 + onClose={props.onClose} 109 + onMoveLeft={props.onMoveLeft} 110 + onMoveRight={props.onMoveRight} 111 + onWidthCycle={props.onWidthCycle} /> 112 + </header> 113 + ); 114 + } 115 + 116 + type FeedBodyProps = { columnId: string; config: string; onOpenThread: (uri: string) => void }; 117 + 118 + function FeedBody(props: FeedBodyProps) { 119 + const config = createMemo(() => parseFeedConfig(props.config)); 120 + const feed = createMemo(() => { 121 + const c = config(); 122 + return c ? feedConfigToSavedFeedItem(c) : null; 123 + }); 124 + 125 + return ( 126 + <Show 127 + when={feed()} 128 + keyed 129 + fallback={ 130 + <div class="flex items-center justify-center p-6 text-sm text-on-surface-variant"> 131 + Invalid feed configuration. 132 + </div> 133 + }> 134 + {(f) => <FeedBodyContent feed={f} onOpenThread={props.onOpenThread} />} 135 + </Show> 136 + ); 137 + } 138 + 139 + type FeedBodyContentProps = { 140 + feed: { id: string; pinned: boolean; type: "feed" | "list" | "timeline"; value: string }; 141 + onOpenThread: (uri: string) => void; 142 + }; 143 + 144 + function FeedBodyContent(props: FeedBodyContentProps) { 145 + const { registerSentinel, state, toggleLike, toggleRepost } = useFeedColumnState(() => props.feed); 146 + const postRefs = new Map<string, HTMLElement>(); 147 + 148 + return ( 149 + <div class="min-h-0 min-w-0 overflow-y-auto overscroll-contain px-3 pb-8 pt-3"> 150 + <FeedContent 151 + activeFeedId={props.feed.id} 152 + activeFeedState={{ 153 + cursor: state.cursor, 154 + error: state.error, 155 + items: state.items, 156 + loading: state.loading, 157 + loadingMore: state.loadingMore, 158 + }} 159 + focusedIndex={-1} 160 + likePendingByUri={state.likePendingByUri} 161 + likePulseUri={null} 162 + onFocusIndex={() => void 0} 163 + onLike={(post: PostView) => toggleLike(post)} 164 + onOpenThread={(uri: string) => Promise.resolve(props.onOpenThread(uri))} 165 + onQuote={() => void 0} 166 + onReply={() => void 0} 167 + onRepost={(post: PostView) => toggleRepost(post)} 168 + postRefs={postRefs} 169 + repostPendingByUri={state.repostPendingByUri} 170 + repostPulseUri={null} 171 + sentinelRef={registerSentinel} 172 + visibleItems={state.items} /> 173 + </div> 174 + ); 175 + } 176 + 177 + function ColumnBody(props: { column: Column; onOpenThread: (uri: string) => void }) { 178 + const diagnosticsConfig = () => parseDiagnosticsConfig(props.column.config); 179 + 180 + return ( 181 + <Switch> 182 + <Match when={props.column.kind === "feed"}> 183 + <FeedBody columnId={props.column.id} config={props.column.config} onOpenThread={props.onOpenThread} /> 184 + </Match> 185 + <Match when={props.column.kind === "explorer"}> 186 + <div class="min-h-0 min-w-0 overflow-hidden"> 187 + <ExplorerPanel /> 188 + </div> 189 + </Match> 190 + <Match when={props.column.kind === "diagnostics"}> 191 + <DiagnosticsColumn did={diagnosticsConfig()?.did ?? ""} /> 192 + </Match> 193 + </Switch> 194 + ); 195 + } 196 + 197 + export function DeckColumn(props: DeckColumnProps) { 198 + const title = () => columnTitle(props.column.kind, props.column.config); 199 + const widthPx = () => COLUMN_WIDTH_PX[props.column.width]; 200 + 201 + return ( 202 + <section 203 + class="flex shrink-0 flex-col overflow-hidden rounded-2xl bg-[rgba(8,8,8,0.32)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]" 204 + style={{ width: `${widthPx()}px` }}> 205 + <ColumnHeader 206 + column={props.column} 207 + title={title()} 208 + onClose={() => props.onClose(props.column.id)} 209 + onMoveLeft={() => props.onMoveLeft(props.column.id)} 210 + onMoveRight={() => props.onMoveRight(props.column.id)} 211 + onWidthCycle={() => props.onWidthChange(props.column.id, cycleWidth(props.column.width))} /> 212 + <div class="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)]"> 213 + <ColumnBody column={props.column} onOpenThread={props.onOpenThread} /> 214 + </div> 215 + </section> 216 + ); 217 + }
+265
src/components/deck/DeckWorkspace.tsx
··· 1 + import { useAppSession } from "$/contexts/app-session"; 2 + import { addColumn, getColumns, removeColumn, reorderColumns, updateColumn } from "$/lib/api/columns"; 3 + import type { Column, ColumnKind, ColumnWidth } from "$/lib/api/columns"; 4 + import { useNavigate } from "@solidjs/router"; 5 + import * as logger from "@tauri-apps/plugin-log"; 6 + import { createEffect, For, onCleanup, onMount, Show } from "solid-js"; 7 + import { createStore, produce } from "solid-js/store"; 8 + import { Motion } from "solid-motionone"; 9 + import { Icon } from "../shared/Icon"; 10 + import { AddColumnPanel } from "./AddColumnPanel"; 11 + import { DeckColumn } from "./DeckColumn"; 12 + 13 + type DeckState = { addPanelOpen: boolean; columns: Column[]; error: string | null; loading: boolean }; 14 + 15 + function DeckToolbar(props: { columnCount: number; onAdd: () => void }) { 16 + return ( 17 + <div class="flex shrink-0 items-center justify-between gap-4 pb-4"> 18 + <div class="min-w-0"> 19 + <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Deck</p> 20 + <p class="m-0 mt-0.5 text-xs uppercase tracking-[0.12em] text-on-surface-variant"> 21 + {props.columnCount === 0 ? "No columns" : `${props.columnCount} column${props.columnCount === 1 ? "" : "s"}`} 22 + </p> 23 + </div> 24 + <button 25 + type="button" 26 + class="inline-flex h-11 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 27 + aria-label="Add column (Ctrl+Shift+N)" 28 + title="Add column (Ctrl+Shift+N)" 29 + onClick={() => props.onAdd()}> 30 + <span class="flex items-center"> 31 + <i class="i-ri-add-line" /> 32 + </span> 33 + Add column 34 + </button> 35 + </div> 36 + ); 37 + } 38 + 39 + function EmptyDeck(props: { onAdd: () => void }) { 40 + return ( 41 + <div class="flex h-64 flex-col items-center justify-center gap-4 text-center"> 42 + <span class="flex items-center text-[2.5rem] text-on-surface-variant opacity-30"> 43 + <i class="i-ri-layout-column-line" /> 44 + </span> 45 + <div> 46 + <p class="m-0 text-sm font-medium text-on-surface">No columns yet</p> 47 + <p class="m-0 mt-1 text-xs text-on-surface-variant"> 48 + Add a feed, explorer, or diagnostics column to get started. 49 + </p> 50 + </div> 51 + <button 52 + type="button" 53 + 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" 54 + onClick={() => props.onAdd()}> 55 + <span class="flex items-center"> 56 + <i class="i-ri-add-line" /> 57 + </span> 58 + Add first column 59 + </button> 60 + </div> 61 + ); 62 + } 63 + 64 + function ColumnList( 65 + props: { 66 + columns: Column[]; 67 + onClose: (id: string) => void; 68 + onMoveLeft: (id: string) => void; 69 + onMoveRight: (id: string) => void; 70 + onOpenThread: (uri: string) => void; 71 + onWidthChange: (id: string, width: ColumnWidth) => void; 72 + }, 73 + ) { 74 + return ( 75 + <div class="flex h-full min-h-96 gap-3 pb-2"> 76 + <For each={props.columns}> 77 + {(column) => ( 78 + <Motion.div 79 + class="flex shrink-0" 80 + style={{ height: "calc(100vh - 12rem)" }} 81 + initial={{ opacity: 0, scale: 0.95 }} 82 + animate={{ opacity: 1, scale: 1 }} 83 + transition={{ duration: 0.18, easing: [0.34, 1.56, 0.64, 1] }}> 84 + <DeckColumn 85 + column={column} 86 + onClose={props.onClose} 87 + onMoveLeft={props.onMoveLeft} 88 + onMoveRight={props.onMoveRight} 89 + onOpenThread={props.onOpenThread} 90 + onWidthChange={props.onWidthChange} /> 91 + </Motion.div> 92 + )} 93 + </For> 94 + </div> 95 + ); 96 + } 97 + 98 + function createDeckKeyboardHandler(onAddColumn: () => void, onCloseLastColumn: () => void) { 99 + return (e: KeyboardEvent) => { 100 + if (!e.ctrlKey && !e.metaKey) return; 101 + if (!e.shiftKey) return; 102 + 103 + if (e.key === "N" || e.key === "n") { 104 + e.preventDefault(); 105 + onAddColumn(); 106 + } else if (e.key === "W" || e.key === "w") { 107 + e.preventDefault(); 108 + onCloseLastColumn(); 109 + } 110 + }; 111 + } 112 + 113 + export function DeckWorkspace() { 114 + const session = useAppSession(); 115 + const navigate = useNavigate(); 116 + 117 + const [state, setState] = createStore<DeckState>({ addPanelOpen: false, columns: [], error: null, loading: true }); 118 + 119 + const activeDid = () => session.activeDid; 120 + 121 + async function loadColumns() { 122 + const did = activeDid(); 123 + if (!did) return; 124 + try { 125 + const cols = await getColumns(did); 126 + setState("columns", cols); 127 + setState("error", null); 128 + } catch (err) { 129 + const message = err instanceof Error ? err.message : String(err); 130 + logger.error(`Failed to load deck columns: ${message}`); 131 + setState("error", message); 132 + } finally { 133 + setState("loading", false); 134 + } 135 + } 136 + 137 + async function handleAdd(kind: ColumnKind, config: string) { 138 + const did = activeDid(); 139 + if (!did) return; 140 + try { 141 + const col = await addColumn(did, kind, config); 142 + setState("columns", (prev) => [...prev, col]); 143 + setState("addPanelOpen", false); 144 + } catch (err) { 145 + logger.error(`Failed to add column: ${String(err)}`); 146 + } 147 + } 148 + 149 + async function handleClose(id: string) { 150 + try { 151 + await removeColumn(id); 152 + setState("columns", (prev) => prev.filter((c) => c.id !== id)); 153 + } catch (err) { 154 + logger.error(`Failed to remove column: ${String(err)}`); 155 + } 156 + } 157 + 158 + async function handleWidthChange(id: string, width: ColumnWidth) { 159 + try { 160 + const updated = await updateColumn(id, { width }); 161 + setState("columns", (prev) => prev.map((c) => (c.id === id ? updated : c))); 162 + } catch (err) { 163 + logger.error(`Failed to update column width: ${String(err)}`); 164 + } 165 + } 166 + 167 + async function handleMoveLeft(id: string) { 168 + const cols = state.columns; 169 + const idx = cols.findIndex((c) => c.id === id); 170 + if (idx === -1 || idx === 0) return; 171 + 172 + const newOrder = cols.map((c) => c.id); 173 + newOrder.splice(idx, 1); 174 + newOrder.splice(idx - 1, 0, id); 175 + 176 + try { 177 + await reorderColumns(newOrder); 178 + setState( 179 + "columns", 180 + produce((draft) => { 181 + const item = draft.splice(idx, 1)[0]; 182 + if (item) draft.splice(idx - 1, 0, item); 183 + }), 184 + ); 185 + } catch (err) { 186 + logger.error(`Failed to reorder columns: ${String(err)}`); 187 + } 188 + } 189 + 190 + async function handleMoveRight(id: string) { 191 + const cols = state.columns; 192 + const idx = cols.findIndex((c) => c.id === id); 193 + if (idx === -1 || idx >= cols.length - 1) return; 194 + 195 + const newOrder = cols.map((c) => c.id); 196 + newOrder.splice(idx, 1); 197 + newOrder.splice(idx + 1, 0, id); 198 + 199 + try { 200 + await reorderColumns(newOrder); 201 + setState( 202 + "columns", 203 + produce((draft) => { 204 + const item = draft.splice(idx, 1)[0]; 205 + if (item) draft.splice(idx + 1, 0, item); 206 + }), 207 + ); 208 + } catch (err) { 209 + logger.error(`Failed to reorder columns: ${String(err)}`); 210 + } 211 + } 212 + 213 + function handleOpenThread(uri: string) { 214 + navigate(`/timeline/thread/${encodeURIComponent(uri)}`); 215 + } 216 + 217 + createEffect(() => { 218 + const handler = createDeckKeyboardHandler(() => setState("addPanelOpen", true), () => { 219 + const last = state.columns.at(-1); 220 + if (last) void handleClose(last.id); 221 + }); 222 + globalThis.addEventListener("keydown", handler); 223 + onCleanup(() => globalThis.removeEventListener("keydown", handler)); 224 + }); 225 + 226 + onMount(() => { 227 + void loadColumns(); 228 + }); 229 + 230 + return ( 231 + <div class="relative flex min-h-0 min-w-0 flex-col"> 232 + <DeckToolbar columnCount={state.columns.length} onAdd={() => setState("addPanelOpen", true)} /> 233 + 234 + <div class="min-h-0 flex-1 overflow-x-auto overscroll-contain"> 235 + <Show when={state.loading}> 236 + <div class="flex h-64 items-center justify-center"> 237 + <Icon iconClass="i-ri-loader-4-line animate-spin text-2xl text-on-surface-variant" /> 238 + </div> 239 + </Show> 240 + 241 + <Show when={!state.loading && state.error}> 242 + <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)]"> 243 + {state.error} 244 + </div> 245 + </Show> 246 + 247 + <Show when={!state.loading && !state.error && state.columns.length === 0}> 248 + <EmptyDeck onAdd={() => setState("addPanelOpen", true)} /> 249 + </Show> 250 + 251 + <Show when={!state.loading && state.columns.length > 0}> 252 + <ColumnList 253 + columns={state.columns} 254 + onClose={handleClose} 255 + onMoveLeft={handleMoveLeft} 256 + onMoveRight={handleMoveRight} 257 + onOpenThread={handleOpenThread} 258 + onWidthChange={handleWidthChange} /> 259 + </Show> 260 + </div> 261 + 262 + <AddColumnPanel open={state.addPanelOpen} onAdd={handleAdd} onClose={() => setState("addPanelOpen", false)} /> 263 + </div> 264 + ); 265 + }
+19
src/components/deck/DiagnosticsColumn.tsx
··· 1 + type DiagnosticsColumnProps = { did: string }; 2 + 3 + /** 4 + * @todo implement this 5 + */ 6 + export function DiagnosticsColumn(props: DiagnosticsColumnProps) { 7 + return ( 8 + <div class="flex min-h-0 flex-col items-center justify-center gap-3 px-6 py-12 text-center"> 9 + <span class="flex items-center justify-center text-[2rem] text-on-surface-variant"> 10 + <i class="i-ri-stethoscope-line" /> 11 + </span> 12 + <p class="m-0 text-sm font-medium text-on-surface">Social Diagnostics</p> 13 + <p class="m-0 text-xs text-on-surface-variant"> 14 + Diagnostics for <span class="font-mono text-primary">{props.did}</span>. 15 + </p> 16 + <p class="m-0 text-xs text-on-surface-variant opacity-60">Full diagnostics panel coming soon.</p> 17 + </div> 18 + ); 19 + }
+9
src/components/deck/ExplorerColumn.tsx
··· 1 + import { ExplorerPanel } from "$/components/explorer/ExplorerPanel"; 2 + 3 + export function ExplorerColumn() { 4 + return ( 5 + <div class="min-h-0 min-w-0 overflow-hidden"> 6 + <ExplorerPanel /> 7 + </div> 8 + ); 9 + }
+89
src/components/deck/types.ts
··· 1 + import type { ColumnWidth, DiagnosticsColumnConfig, ExplorerColumnConfig, FeedColumnConfig } from "$/lib/api/columns"; 2 + import type { SavedFeedItem } from "$/lib/types"; 3 + 4 + export const COLUMN_WIDTH_PX: Record<ColumnWidth, number> = { narrow: 320, standard: 420, wide: 560 }; 5 + 6 + export function cycleWidth(current: ColumnWidth): ColumnWidth { 7 + switch (current) { 8 + case "narrow": { 9 + return "standard"; 10 + } 11 + case "standard": { 12 + return "wide"; 13 + } 14 + case "wide": { 15 + return "narrow"; 16 + } 17 + } 18 + } 19 + 20 + export function parseFeedConfig(config: string): FeedColumnConfig | null { 21 + try { 22 + const parsed = JSON.parse(config) as unknown; 23 + if (parsed && typeof parsed === "object" && "feedType" in parsed) { 24 + return parsed as FeedColumnConfig; 25 + } 26 + return null; 27 + } catch { 28 + return null; 29 + } 30 + } 31 + 32 + export function parseExplorerConfig(config: string): ExplorerColumnConfig | null { 33 + try { 34 + const parsed = JSON.parse(config) as unknown; 35 + if (parsed && typeof parsed === "object" && "targetUri" in parsed) { 36 + return parsed as ExplorerColumnConfig; 37 + } 38 + return null; 39 + } catch { 40 + return null; 41 + } 42 + } 43 + 44 + export function parseDiagnosticsConfig(config: string): DiagnosticsColumnConfig | null { 45 + try { 46 + const parsed = JSON.parse(config) as unknown; 47 + if (parsed && typeof parsed === "object" && "did" in parsed) { 48 + return parsed as DiagnosticsColumnConfig; 49 + } 50 + return null; 51 + } catch { 52 + return null; 53 + } 54 + } 55 + 56 + export function feedConfigToSavedFeedItem(config: FeedColumnConfig): SavedFeedItem { 57 + return { 58 + id: config.feedUri || "following", 59 + pinned: false, 60 + type: config.feedType, 61 + value: config.feedUri || "following", 62 + }; 63 + } 64 + 65 + export function columnTitle(kind: string, config: string): string { 66 + switch (kind) { 67 + case "feed": { 68 + const parsed = parseFeedConfig(config); 69 + if (!parsed) return "Feed"; 70 + if (parsed.feedType === "timeline") return "Timeline"; 71 + const segment = parsed.feedUri.split("/").at(-1)?.trim(); 72 + return segment ? segment.replaceAll("-", " ") : (parsed.feedType === "list" ? "List" : "Feed"); 73 + } 74 + case "explorer": { 75 + const parsed = parseExplorerConfig(config); 76 + if (!parsed?.targetUri) return "Explorer"; 77 + return parsed.targetUri.length > 30 ? `${parsed.targetUri.slice(0, 30)}…` : parsed.targetUri; 78 + } 79 + case "diagnostics": { 80 + const parsed = parseDiagnosticsConfig(config); 81 + return parsed?.did ?? "Diagnostics"; 82 + } 83 + default: { 84 + return "Column"; 85 + } 86 + } 87 + } 88 + 89 + export { type Column, type ColumnWidth } from "$/lib/api/columns";
+174
src/components/deck/useFeedColumnState.ts
··· 1 + import { getFeedPage, likePost, repost, unlikePost, unrepost } from "$/lib/api/feeds"; 2 + import type { FeedViewPost, PostView, SavedFeedItem } from "$/lib/types"; 3 + import * as logger from "@tauri-apps/plugin-log"; 4 + import { onCleanup, onMount } from "solid-js"; 5 + import { createStore } from "solid-js/store"; 6 + 7 + const PAGE_LIMIT = 20; 8 + 9 + export type FeedColumnState = { 10 + cursor: string | null; 11 + error: string | null; 12 + items: FeedViewPost[]; 13 + likePendingByUri: Record<string, boolean>; 14 + loading: boolean; 15 + loadingMore: boolean; 16 + repostPendingByUri: Record<string, boolean>; 17 + }; 18 + 19 + export function useFeedColumnState(getFeed: () => SavedFeedItem) { 20 + const [state, setState] = createStore<FeedColumnState>({ 21 + cursor: null, 22 + error: null, 23 + items: [], 24 + likePendingByUri: {}, 25 + loading: true, 26 + loadingMore: false, 27 + repostPendingByUri: {}, 28 + }); 29 + 30 + let observer: IntersectionObserver | undefined; 31 + 32 + async function load(cursor: string | null = null) { 33 + try { 34 + const page = await getFeedPage(getFeed(), cursor, PAGE_LIMIT); 35 + 36 + if (cursor) { 37 + setState("items", (prev) => [...prev, ...page.feed]); 38 + } else { 39 + setState("items", page.feed); 40 + } 41 + setState("cursor", page.cursor ?? null); 42 + setState("error", null); 43 + } catch (err) { 44 + const message = err instanceof Error ? err.message : String(err); 45 + logger.error(`Feed column load failed: ${message}`); 46 + setState("error", message); 47 + } finally { 48 + setState("loading", false); 49 + setState("loadingMore", false); 50 + } 51 + } 52 + 53 + async function loadMore() { 54 + if (state.loadingMore || state.loading || !state.cursor) return; 55 + setState("loadingMore", true); 56 + await load(state.cursor); 57 + } 58 + 59 + async function refresh() { 60 + setState("loading", true); 61 + setState("cursor", null); 62 + setState("items", []); 63 + await load(null); 64 + } 65 + 66 + async function toggleLike(post: PostView) { 67 + if (state.likePendingByUri[post.uri]) return; 68 + setState("likePendingByUri", post.uri, true); 69 + 70 + try { 71 + const likeUri = post.viewer?.like; 72 + if (likeUri) { 73 + await unlikePost(likeUri); 74 + setState("items", (items) => 75 + items.map((item) => { 76 + if (item.post.uri !== post.uri) return item; 77 + return { 78 + ...item, 79 + post: { 80 + ...item.post, 81 + likeCount: (item.post.likeCount ?? 1) - 1, 82 + viewer: { ...item.post.viewer, like: undefined }, 83 + }, 84 + }; 85 + })); 86 + } else { 87 + const result = await likePost(post.uri, post.cid); 88 + setState("items", (items) => 89 + items.map((item) => { 90 + if (item.post.uri !== post.uri) return item; 91 + return { 92 + ...item, 93 + post: { 94 + ...item.post, 95 + likeCount: (item.post.likeCount ?? 0) + 1, 96 + viewer: { ...item.post.viewer, like: result.uri }, 97 + }, 98 + }; 99 + })); 100 + } 101 + } catch (err) { 102 + logger.error(`Like toggle failed: ${String(err)}`); 103 + } finally { 104 + setState("likePendingByUri", post.uri, false); 105 + } 106 + } 107 + 108 + async function toggleRepost(post: PostView) { 109 + if (state.repostPendingByUri[post.uri]) return; 110 + setState("repostPendingByUri", post.uri, true); 111 + 112 + try { 113 + const repostUri = post.viewer?.repost; 114 + if (repostUri) { 115 + await unrepost(repostUri); 116 + setState("items", (items) => 117 + items.map((item) => { 118 + if (item.post.uri !== post.uri) return item; 119 + return { 120 + ...item, 121 + post: { 122 + ...item.post, 123 + repostCount: (item.post.repostCount ?? 1) - 1, 124 + viewer: { ...item.post.viewer, repost: undefined }, 125 + }, 126 + }; 127 + })); 128 + } else { 129 + const result = await repost(post.uri, post.cid); 130 + setState("items", (items) => 131 + items.map((item) => { 132 + if (item.post.uri !== post.uri) return item; 133 + return { 134 + ...item, 135 + post: { 136 + ...item.post, 137 + repostCount: (item.post.repostCount ?? 0) + 1, 138 + viewer: { ...item.post.viewer, repost: result.uri }, 139 + }, 140 + }; 141 + })); 142 + } 143 + } catch (err) { 144 + logger.error(`Repost toggle failed: ${String(err)}`); 145 + } finally { 146 + setState("repostPendingByUri", post.uri, false); 147 + } 148 + } 149 + 150 + function registerSentinel(element: HTMLDivElement) { 151 + observer?.disconnect(); 152 + 153 + if (!element) return; 154 + 155 + observer = new IntersectionObserver((entries) => { 156 + const entry = entries[0]; 157 + if (entry?.isIntersecting) { 158 + void loadMore(); 159 + } 160 + }, { threshold: 0.1 }); 161 + 162 + observer.observe(element); 163 + } 164 + 165 + onMount(() => { 166 + void load(null); 167 + }); 168 + 169 + onCleanup(() => { 170 + observer?.disconnect(); 171 + }); 172 + 173 + return { refresh, registerSentinel, state, toggleLike, toggleRepost }; 174 + }
+1
src/components/rail/AppRail.tsx
··· 42 42 href="/notifications" 43 43 label="Notifications" 44 44 icon="notifications" /> 45 + <RailButton end compact={props.collapsed} href="/deck" label="Deck" icon="deck" /> 45 46 <RailButton end compact={props.collapsed} href="/explorer" label="AT Explorer" icon="explorer" /> 46 47 <RailButton end compact={props.collapsed} href="/settings" label="Settings" icon="settings" /> 47 48 </Show>
+13 -1
src/components/shared/Icon.tsx
··· 50 50 | "notifications" 51 51 | "user" 52 52 | "services" 53 - | "theme"; 53 + | "theme" 54 + | "deck" 55 + | "list" 56 + | "rss"; 54 57 55 58 type IconProps = JSX.HTMLAttributes<HTMLSpanElement> & { 56 59 class?: string; ··· 154 157 </Match> 155 158 <Match when={local.kind === "settings"}> 156 159 <i class="i-ri-settings-3-line" /> 160 + </Match> 161 + <Match when={local.kind === "deck"}> 162 + <i class="i-ri-layout-column-line" /> 163 + </Match> 164 + <Match when={local.kind === "list"}> 165 + <i class="i-ri-list-check-3-line" /> 166 + </Match> 167 + <Match when={local.kind === "rss"}> 168 + <i class="i-ri-rss-line" /> 157 169 </Match> 158 170 </Switch> 159 171 </span>
+41
src/lib/api/columns.ts
··· 1 + import { invoke } from "@tauri-apps/api/core"; 2 + 3 + export type ColumnKind = "feed" | "explorer" | "diagnostics"; 4 + 5 + export type ColumnWidth = "narrow" | "standard" | "wide"; 6 + 7 + export type Column = { 8 + id: string; 9 + accountDid: string; 10 + kind: ColumnKind; 11 + config: string; 12 + position: number; 13 + width: ColumnWidth; 14 + createdAt: string; 15 + }; 16 + 17 + export type FeedColumnConfig = { feedUri: string; feedType: "timeline" | "feed" | "list" }; 18 + 19 + export type ExplorerColumnConfig = { targetUri: string }; 20 + 21 + export type DiagnosticsColumnConfig = { did: string }; 22 + 23 + export function getColumns(accountDid: string) { 24 + return invoke<Column[]>("get_columns", { accountDid }); 25 + } 26 + 27 + export function addColumn(accountDid: string, kind: ColumnKind, config: string, position?: number) { 28 + return invoke<Column>("add_column", { accountDid, config, kind, position: position ?? null }); 29 + } 30 + 31 + export function removeColumn(id: string) { 32 + return invoke<void>("remove_column", { id }); 33 + } 34 + 35 + export function reorderColumns(ids: string[]) { 36 + return invoke<void>("reorder_columns", { ids }); 37 + } 38 + 39 + export function updateColumn(id: string, opts: { config?: string; width?: ColumnWidth }) { 40 + return invoke<Column>("update_column", { config: opts.config ?? null, id, width: opts.width ?? null }); 41 + }
+9 -1
src/router.tsx
··· 4 4 import type { RouteSectionProps } from "@solidjs/router"; 5 5 import { type Component, createEffect, type JSX, type ParentProps, Show } from "solid-js"; 6 6 import { Dynamic } from "solid-js/web"; 7 + import { DeckWorkspace } from "./components/deck/DeckWorkspace"; 7 8 import { ExplorerPanel } from "./components/explorer/ExplorerPanel"; 8 9 import { SearchPanel } from "./components/search/SearchPanel"; 9 10 import { SettingsPanel } from "./components/settings/SettingsPanel"; ··· 41 42 } 42 43 }); 43 44 44 - const fullWidthShell = () => location.pathname === "/explorer"; 45 + const fullWidthShell = () => location.pathname === "/explorer" || location.pathname === "/deck"; 45 46 46 47 return ( 47 48 <Show ··· 99 100 100 101 const ComposerRoute = () => <ProtectedRouteView>{props.renderComposer()}</ProtectedRouteView>; 101 102 103 + const DeckRoute = () => ( 104 + <ProtectedRouteView> 105 + <DeckWorkspace /> 106 + </ProtectedRouteView> 107 + ); 108 + 102 109 const ExplorerRoute = () => ( 103 110 <ProtectedRouteView> 104 111 <ExplorerPanel /> ··· 128 135 <Route path="/composer" component={ComposerRoute} /> 129 136 <Route path="/search" component={SearchRoute} /> 130 137 <Route path="/notifications" component={NotificationsRoute} /> 138 + <Route path="/deck" component={DeckRoute} /> 131 139 <Route path="/explorer" component={ExplorerRoute} /> 132 140 <Route path="/settings" component={SettingsRoute} /> 133 141 <Route path="*404" component={NotFoundRoute} />