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: add settings panel with logs, notifications, and service configurations

+1833 -239
+5 -5
docs/tasks/06-settings.md
··· 16 16 17 17 ### Frontend - Settings View 18 18 19 - - [ ] Settings route (`/settings`) accessible from app rail icon (`Icon` with kind `settings`) 20 - - [ ] Section-based layout using `surface_container` cards with `lg` radius: 19 + - [x] Settings route (`/settings`) accessible from app rail icon (`Icon` with kind `settings`) 20 + - [x] Section-based layout using `surface_container` cards with `lg` radius: 21 21 1. **Appearance** - Theme toggle (light/dark/auto), `Motion` crossfade on theme switch 22 22 2. **Timeline** - Refresh interval selector (30s, 1m, 2m, 5m, manual) 23 23 3. **Notifications** - Toggle desktop notifications, badge count, notification sound ··· 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 29 - - [ ] `Presence` slide transitions between setting sections 30 - - [ ] Keyboard shortcut: `,` to open settings from anywhere 31 - - [ ] Confirmation modal for destructive actions (clear cache, reset app, remove account) using glass overlay 29 + - [x] `Presence` slide transitions between setting sections 30 + - [x] Keyboard shortcut: `,` to open settings from anywhere 31 + - [x] Confirmation modal for destructive actions (clear cache, reset app, remove account) using glass overlay
+27 -4
src/App.tsx
··· 1 1 import { getCurrentWindow } from "@tauri-apps/api/window"; 2 2 import "@fontsource-variable/google-sans"; 3 + import { useNavigate } from "@solidjs/router"; 3 4 import type { ParentProps } from "solid-js"; 4 - import { Show } from "solid-js"; 5 + import { createEffect, onCleanup, Show } from "solid-js"; 5 6 import "./App.css"; 6 7 import { AccountLedger } from "./components/account/AccountLedger"; 7 8 import { AppRail } from "./components/AppRail"; ··· 12 13 import { HeaderPanel } from "./components/panels/Header"; 13 14 import { SessionSpotlight } from "./components/Session"; 14 15 import { ErrorToast } from "./components/shared/ErrorToast"; 16 + import { AppPreferencesProvider } from "./contexts/app-preferences"; 15 17 import { AppSessionProvider, useAppSession } from "./contexts/app-session"; 16 18 import { AppShellUiProvider, useAppShellUi } from "./contexts/app-shell-ui"; 17 19 import { AppRouter } from "./router"; ··· 20 22 21 23 type AppShellProps = ParentProps<{ fullWidth?: boolean }>; 22 24 25 + function createSettingsShortcutHandler(hasSession: boolean, navigate: (path: string) => void) { 26 + return (e: KeyboardEvent) => { 27 + if (e.key === "," && !e.ctrlKey && !e.metaKey && !e.altKey) { 28 + const activeElement = document.activeElement; 29 + const isInputFocused = activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement; 30 + if (!isInputFocused && hasSession) { 31 + navigate("/settings"); 32 + } 33 + } 34 + }; 35 + } 36 + 23 37 function AppShell(props: AppShellProps) { 24 38 const session = useAppSession(); 25 39 const shell = useAppShellUi(); 40 + const navigate = useNavigate(); 41 + 42 + createEffect(() => { 43 + const handler = createSettingsShortcutHandler(session.hasSession, navigate); 44 + globalThis.addEventListener("keydown", handler); 45 + onCleanup(() => globalThis.removeEventListener("keydown", handler)); 46 + }); 26 47 27 48 return ( 28 49 <> ··· 99 120 function App() { 100 121 return ( 101 122 <AppSessionProvider> 102 - <AppShellUiProvider> 103 - <AppContent /> 104 - </AppShellUiProvider> 123 + <AppPreferencesProvider> 124 + <AppShellUiProvider> 125 + <AppContent /> 126 + </AppShellUiProvider> 127 + </AppPreferencesProvider> 105 128 </AppSessionProvider> 106 129 ); 107 130 }
+1
src/components/AppRail.tsx
··· 42 42 label="Notifications" 43 43 icon="notifications" /> 44 44 <RailButton end compact={props.collapsed} href="/explorer" label="AT Explorer" icon="explorer" /> 45 + <RailButton end compact={props.collapsed} href="/settings" label="Settings" icon="settings" /> 45 46 </Show> 46 47 </div> 47 48 );
+37 -10
src/components/search/EmbeddingsSettings.test.tsx
··· 1 + import { AppPreferencesProvider } from "$/contexts/app-preferences"; 1 2 import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 3 import { beforeEach, describe, expect, it, vi } from "vitest"; 3 4 import { EmbeddingsSettings } from "./EmbeddingsSettings"; ··· 5 6 const getEmbeddingsConfigMock = vi.hoisted(() => vi.fn()); 6 7 const prepareEmbeddingsModelMock = vi.hoisted(() => vi.fn()); 7 8 const setEmbeddingsEnabledMock = vi.hoisted(() => vi.fn()); 9 + const getSettingsMock = vi.hoisted(() => vi.fn()); 10 + const updateSettingMock = vi.hoisted(() => vi.fn()); 8 11 9 12 vi.mock( 10 13 "$/lib/api/search", ··· 15 18 }), 16 19 ); 17 20 21 + vi.mock("$/lib/api/settings", () => ({ getSettings: getSettingsMock, updateSetting: updateSettingMock })); 22 + 18 23 vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); 19 24 25 + function renderEmbeddingsSettings() { 26 + render(() => ( 27 + <AppPreferencesProvider> 28 + <EmbeddingsSettings /> 29 + </AppPreferencesProvider> 30 + )); 31 + } 32 + 20 33 describe("EmbeddingsSettings", () => { 21 34 beforeEach(() => { 22 35 getEmbeddingsConfigMock.mockReset(); 23 36 prepareEmbeddingsModelMock.mockReset(); 24 37 setEmbeddingsEnabledMock.mockReset(); 38 + getSettingsMock.mockReset(); 39 + updateSettingMock.mockReset(); 25 40 41 + getSettingsMock.mockResolvedValue({ 42 + theme: "auto", 43 + timelineRefreshSecs: 60, 44 + notificationsDesktop: true, 45 + notificationsBadge: true, 46 + notificationsSound: false, 47 + embeddingsEnabled: true, 48 + constellationUrl: "https://constellation.microcosm.blue", 49 + spacedustUrl: "https://spacedust.microcosm.blue", 50 + spacedustInstant: false, 51 + spacedustEnabled: false, 52 + globalShortcut: "Ctrl+Shift+N", 53 + }); 26 54 getEmbeddingsConfigMock.mockResolvedValue({ 27 55 enabled: true, 28 56 modelName: "nomic-embed-text-v1.5", ··· 41 69 }); 42 70 43 71 it("renders embeddings settings with model info", async () => { 44 - render(() => <EmbeddingsSettings />); 72 + renderEmbeddingsSettings(); 45 73 46 74 expect(await screen.findByText("Semantic Search")).toBeInTheDocument(); 47 75 expect(await screen.findByText(/nomic-embed-text-v1\.5/)).toBeInTheDocument(); ··· 49 77 }); 50 78 51 79 it("shows toggle in enabled state when embeddings are enabled", async () => { 52 - render(() => <EmbeddingsSettings />); 80 + renderEmbeddingsSettings(); 53 81 54 82 const toggle = await screen.findByRole("switch"); 55 83 expect(toggle).toHaveAttribute("aria-checked", "true"); ··· 64 92 downloadActive: false, 65 93 }); 66 94 67 - render(() => <EmbeddingsSettings />); 95 + renderEmbeddingsSettings(); 68 96 69 97 const toggle = await screen.findByRole("switch"); 70 98 expect(toggle).toHaveAttribute("aria-checked", "false"); ··· 85 113 downloadActive: false, 86 114 }); 87 115 88 - render(() => <EmbeddingsSettings />); 116 + renderEmbeddingsSettings(); 89 117 90 118 const toggle = await screen.findByRole("switch"); 91 119 expect(toggle).toHaveAttribute("aria-checked", "true"); ··· 114 142 downloadFileTotal: 5, 115 143 }); 116 144 117 - render(() => <EmbeddingsSettings />); 145 + renderEmbeddingsSettings(); 118 146 119 147 expect(await screen.findAllByText(/downloading model files/i)).toHaveLength(2); 120 148 expect(await screen.findByText(/0%/)).toBeInTheDocument(); 121 149 }); 122 150 123 151 it("displays semantic search description", async () => { 124 - render(() => <EmbeddingsSettings />); 152 + renderEmbeddingsSettings(); 125 153 expect(await screen.findByText(/conceptually similar posts/i)).toBeInTheDocument(); 126 154 }); 127 155 128 156 it("handles errors when loading config gracefully", async () => { 129 157 getEmbeddingsConfigMock.mockRejectedValue(new Error("Failed to load")); 130 158 131 - render(() => <EmbeddingsSettings />); 159 + renderEmbeddingsSettings(); 132 160 133 - // Should still render without crashing 134 161 await waitFor(() => { 135 162 expect(getEmbeddingsConfigMock).toHaveBeenCalled(); 136 163 }); 164 + expect(await screen.findByText("Semantic Search")).toBeInTheDocument(); 137 165 }); 138 166 139 167 it("handles errors when toggling gracefully", async () => { 140 168 setEmbeddingsEnabledMock.mockRejectedValue(new Error("Failed to save")); 141 169 142 - render(() => <EmbeddingsSettings />); 170 + renderEmbeddingsSettings(); 143 171 144 172 const toggle = await screen.findByRole("switch"); 145 173 fireEvent.click(toggle); ··· 148 176 expect(setEmbeddingsEnabledMock).toHaveBeenCalled(); 149 177 }); 150 178 151 - // Toggle state should remain unchanged on error 152 179 expect(toggle).toHaveAttribute("aria-checked", "true"); 153 180 }); 154 181 });
+14 -51
src/components/search/EmbeddingsSettings.tsx
··· 1 1 /* eslint react/jsx-max-depth: ["error", { "max": 5 }] */ 2 2 import { Icon } from "$/components/shared/Icon"; 3 + import { useAppPreferences } from "$/contexts/app-preferences"; 3 4 import type { EmbeddingsConfig } from "$/lib/api/search"; 4 - import { getEmbeddingsConfig, prepareEmbeddingsModel, setEmbeddingsEnabled } from "$/lib/api/search"; 5 5 import { formatEtaSeconds, formatProgress } from "$/lib/utils/text"; 6 - import * as logger from "@tauri-apps/plugin-log"; 7 6 import { createEffect, createMemo, createSignal, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 8 7 import { Motion, Presence } from "solid-motionone"; 9 8 ··· 123 122 ); 124 123 } 125 124 126 - type EmbeddingsSettingsProps = { onConfigChange?: (config: EmbeddingsConfig) => void }; 127 - 128 - export function EmbeddingsSettings(props: EmbeddingsSettingsProps) { 129 - const [config, setConfig] = createSignal<EmbeddingsConfig | null>(null); 130 - const [loading, setLoading] = createSignal(true); 125 + export function EmbeddingsSettings() { 126 + const preferences = useAppPreferences(); 131 127 const [autoPrepareStarted, setAutoPrepareStarted] = createSignal(false); 132 - 133 - async function loadConfig() { 134 - try { 135 - setLoading(true); 136 - const nextConfig = await getEmbeddingsConfig(); 137 - setConfig(nextConfig); 138 - props.onConfigChange?.(nextConfig); 139 - } catch (error) { 140 - logger.error("failed to load embeddings config", { keyValues: { error: String(error) } }); 141 - } finally { 142 - setLoading(false); 143 - } 144 - } 145 - 146 - async function refreshConfig() { 147 - try { 148 - const nextConfig = await getEmbeddingsConfig(); 149 - setConfig(nextConfig); 150 - props.onConfigChange?.(nextConfig); 151 - } catch (error) { 152 - logger.error("failed to refresh embeddings config", { keyValues: { error: String(error) } }); 153 - } 154 - } 128 + const config = () => preferences.embeddingsConfig; 155 129 156 130 async function prepareModel() { 157 - try { 158 - const nextConfig = await prepareEmbeddingsModel(); 159 - setConfig(nextConfig); 160 - props.onConfigChange?.(nextConfig); 161 - } catch (error) { 162 - logger.error("failed to prepare embeddings model", { keyValues: { error: String(error) } }); 163 - await refreshConfig(); 164 - } 131 + await preferences.prepareEmbeddingsModel(); 165 132 } 166 133 167 134 async function handleToggle() { ··· 171 138 } 172 139 173 140 const nextEnabled = !current.enabled; 174 - try { 175 - await setEmbeddingsEnabled(nextEnabled); 176 - if (!nextEnabled) { 177 - setAutoPrepareStarted(false); 178 - } 179 - 180 - await loadConfig(); 181 - } catch (error) { 182 - logger.error("failed to set embeddings enabled", { keyValues: { error: String(error) } }); 141 + await preferences.setEmbeddingsEnabled(nextEnabled); 142 + if (!nextEnabled) { 143 + setAutoPrepareStarted(false); 183 144 } 184 145 } 185 146 ··· 195 156 }); 196 157 197 158 createEffect(() => { 198 - void loadConfig(); 159 + if (!config() && !preferences.embeddingsLoading) { 160 + void preferences.loadEmbeddingsConfig(); 161 + } 199 162 }); 200 163 201 164 createEffect(() => { ··· 219 182 220 183 onMount(() => { 221 184 const interval = setInterval(() => { 222 - if (config()?.downloadActive) { 223 - void refreshConfig(); 185 + if (preferences.embeddingsConfig?.downloadActive) { 186 + void preferences.loadEmbeddingsConfig(); 224 187 } 225 188 }, 1000); 226 189 ··· 229 192 230 193 return ( 231 194 <section class="panel-surface grid gap-4 p-5"> 232 - <EmbedSettingsHeader config={config()} isLoading={loading()} handleToggle={handleToggle} /> 195 + <EmbedSettingsHeader config={config()} isLoading={preferences.embeddingsLoading} handleToggle={handleToggle} /> 233 196 234 197 <Presence> 235 198 <Show when={config()?.enabled && (!config()?.downloaded || config()?.downloadActive || config()?.lastError)}>
+57 -14
src/components/search/SearchEmptyState.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 2 import { Match, Show, Switch } from "solid-js"; 3 3 4 - type SearchEmptyStateProps = { reason: "initial" | "no-results" | "no-sync" }; 4 + type SearchEmptyStateProps = { reason: "error" | "initial" | "no-results" | "no-sync"; scope?: "local" | "network" }; 5 5 6 6 export function SearchEmptyState(props: SearchEmptyStateProps) { 7 7 return ( 8 8 <div class="text-center"> 9 9 <EmptyStateIcon reason={props.reason} /> 10 - <EmptyStateContent reason={props.reason} /> 10 + <EmptyStateContent reason={props.reason} scope={props.scope ?? "local"} /> 11 11 </div> 12 12 ); 13 13 } ··· 24 24 ); 25 25 } 26 26 27 - function EmptyStateContent(props: { reason: string }) { 27 + function EmptyStateContent(props: { reason: string; scope: "local" | "network" }) { 28 28 return ( 29 29 <Switch> 30 30 <Match when={props.reason === "initial"}> 31 - <InitialContent /> 31 + <InitialContent scope={props.scope} /> 32 32 </Match> 33 33 34 34 <Match when={props.reason === "no-results"}> 35 - <NoResultsContent /> 35 + <NoResultsContent scope={props.scope} /> 36 36 </Match> 37 37 38 38 <Match when={props.reason === "no-sync"}> 39 39 <NoSyncContent /> 40 + </Match> 41 + 42 + <Match when={props.reason === "error"}> 43 + <ErrorContent scope={props.scope} /> 40 44 </Match> 41 45 </Switch> 42 46 ); 43 47 } 44 48 45 - function InitialContent() { 49 + function InitialContent(props: { scope: "local" | "network" }) { 46 50 return ( 47 51 <> 48 - <h3 class="mb-1 text-base font-medium text-on-surface">Search your saved & liked posts</h3> 49 - <p class="m-0 text-sm text-on-surface-variant"> 50 - Type a query above to search through the posts you liked or bookmarked. 51 - </p> 52 + <Switch> 53 + <Match when={props.scope === "network"}> 54 + <h3 class="mb-1 text-base font-medium text-on-surface">Search public posts across the network</h3> 55 + <p class="m-0 text-sm text-on-surface-variant"> 56 + Type a query above to search Bluesky directly without relying on your local index. 57 + </p> 58 + </Match> 59 + <Match when={props.scope === "local"}> 60 + <h3 class="mb-1 text-base font-medium text-on-surface">Search your saved & liked posts</h3> 61 + <p class="m-0 text-sm text-on-surface-variant"> 62 + Type a query above to search through the posts you liked or bookmarked. 63 + </p> 64 + </Match> 65 + </Switch> 52 66 <KeyboardShortcuts /> 53 67 </> 54 68 ); ··· 69 83 ); 70 84 } 71 85 72 - function NoResultsContent() { 86 + function NoResultsContent(props: { scope: "local" | "network" }) { 73 87 return ( 74 88 <> 75 89 <h3 class="mb-1 text-base font-medium text-on-surface">No results found</h3> 76 - <p class="m-0 text-sm text-on-surface-variant"> 77 - Try adjusting your search terms or switch to a different search mode. 78 - </p> 90 + <Switch> 91 + <Match when={props.scope === "network"}> 92 + <p class="m-0 text-sm text-on-surface-variant"> 93 + Try a broader query or switch to local search if you want to search your synced posts instead. 94 + </p> 95 + </Match> 96 + <Match when={props.scope === "local"}> 97 + <p class="m-0 text-sm text-on-surface-variant"> 98 + Try adjusting your search terms or switch to a different search mode. 99 + </p> 100 + </Match> 101 + </Switch> 79 102 </> 80 103 ); 81 104 } ··· 90 113 </> 91 114 ); 92 115 } 116 + 117 + function ErrorContent(props: { scope: "local" | "network" }) { 118 + return ( 119 + <> 120 + <h3 class="mb-1 text-base font-medium text-on-surface">Search failed</h3> 121 + <Switch> 122 + <Match when={props.scope === "network"}> 123 + <p class="m-0 text-sm text-on-surface-variant"> 124 + The network request did not complete. Retry the query or switch to local search while the network recovers. 125 + </p> 126 + </Match> 127 + <Match when={props.scope === "local"}> 128 + <p class="m-0 text-sm text-on-surface-variant"> 129 + The local index request did not complete. Retry the query or sync again if your index is stale. 130 + </p> 131 + </Match> 132 + </Switch> 133 + </> 134 + ); 135 + }
+7 -31
src/components/search/SearchPanel.test.tsx
··· 7 7 const searchPostsNetworkMock = vi.hoisted(() => vi.fn()); 8 8 const getSyncStatusMock = vi.hoisted(() => vi.fn()); 9 9 const syncPostsMock = vi.hoisted(() => vi.fn()); 10 - const getEmbeddingsConfigMock = vi.hoisted(() => vi.fn()); 11 - const prepareEmbeddingsModelMock = vi.hoisted(() => vi.fn()); 12 - const setEmbeddingsEnabledMock = vi.hoisted(() => vi.fn()); 13 10 14 11 vi.mock( 15 12 "$/lib/api/search", 16 13 () => ({ 17 - getEmbeddingsConfig: getEmbeddingsConfigMock, 18 - prepareEmbeddingsModel: prepareEmbeddingsModelMock, 19 - setEmbeddingsEnabled: setEmbeddingsEnabledMock, 20 14 searchPosts: searchPostsMock, 21 15 searchPostsNetwork: searchPostsNetworkMock, 22 16 getSyncStatus: getSyncStatusMock, ··· 41 35 searchPostsNetworkMock.mockReset(); 42 36 getSyncStatusMock.mockReset(); 43 37 syncPostsMock.mockReset(); 44 - getEmbeddingsConfigMock.mockReset(); 45 - prepareEmbeddingsModelMock.mockReset(); 46 - setEmbeddingsEnabledMock.mockReset(); 47 38 48 39 getSyncStatusMock.mockResolvedValue([]); 49 40 syncPostsMock.mockResolvedValue({ ··· 52 43 postCount: 100, 53 44 lastSyncedAt: "2026-03-29T12:00:00.000Z", 54 45 }); 55 - getEmbeddingsConfigMock.mockResolvedValue({ 56 - enabled: true, 57 - modelName: "nomic-embed-text-v1.5", 58 - dimensions: 768, 59 - downloaded: true, 60 - downloadActive: false, 61 - }); 62 - prepareEmbeddingsModelMock.mockResolvedValue({ 63 - enabled: true, 64 - modelName: "nomic-embed-text-v1.5", 65 - dimensions: 768, 66 - downloaded: true, 67 - downloadActive: false, 68 - }); 69 - setEmbeddingsEnabledMock.mockResolvedValue(void 0); 70 46 }); 71 47 72 48 it("renders the search panel with initial state", async () => { 73 49 renderSearchPanel(); 74 50 75 - expect(await screen.findByPlaceholderText("Search your saved & liked posts...")).toBeInTheDocument(); 51 + expect(await screen.findByPlaceholderText("Search public posts across Bluesky...")).toBeInTheDocument(); 76 52 expect(screen.getByText("Network")).toBeInTheDocument(); 77 53 expect(screen.getByText("Keyword")).toBeInTheDocument(); 78 54 expect(screen.getByText("Semantic")).toBeInTheDocument(); ··· 103 79 104 80 renderSearchPanel(); 105 81 106 - const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); 82 + const input = await screen.findByRole("textbox"); 107 83 fireEvent.input(input, { target: { value: "test query" } }); 108 84 109 85 vi.advanceTimersByTime(350); ··· 136 112 fireEvent.click(keywordButton); 137 113 expect(keywordButton).toHaveAttribute("aria-pressed", "true"); 138 114 139 - const input = screen.getByPlaceholderText("Search your saved & liked posts..."); 115 + const input = screen.getByRole("textbox"); 140 116 fireEvent.input(input, { target: { value: "test query" } }); 141 117 142 118 await vi.advanceTimersByTimeAsync(350); ··· 150 126 it("cycles through modes with Tab key", async () => { 151 127 renderSearchPanel(); 152 128 153 - const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); 129 + const input = await screen.findByRole("textbox"); 154 130 input.focus(); 155 131 fireEvent.keyDown(input, { key: "Tab" }); 156 132 ··· 170 146 171 147 renderSearchPanel(); 172 148 173 - const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); 149 + const input = await screen.findByRole("textbox"); 174 150 fireEvent.input(input, { target: { value: "test" } }); 175 151 vi.advanceTimersByTime(350); 176 152 ··· 188 164 189 165 renderSearchPanel(); 190 166 191 - const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); 167 + const input = await screen.findByRole("textbox"); 192 168 fireEvent.input(input, { target: { value: "test" } }); 193 169 vi.advanceTimersByTime(350); 194 170 ··· 206 182 const keywordButton = screen.getByRole("button", { name: /keyword/i }); 207 183 fireEvent.click(keywordButton); 208 184 209 - const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); 185 + const input = await screen.findByRole("textbox"); 210 186 fireEvent.input(input, { target: { value: "nonexistent" } }); 211 187 vi.advanceTimersByTime(350); 212 188
+119 -109
src/components/search/SearchPanel.tsx
··· 1 1 import { Icon, SearchModeIcon } from "$/components/shared/Icon"; 2 + import { useAppPreferences } from "$/contexts/app-preferences"; 2 3 import { useAppSession } from "$/contexts/app-session"; 3 4 import { 4 - type EmbeddingsConfig, 5 - getEmbeddingsConfig, 6 5 type LocalPostResult, 7 6 type NetworkSearchResult, 8 7 type SearchMode, ··· 14 13 import { normalizeError } from "$/lib/utils/text"; 15 14 import * as logger from "@tauri-apps/plugin-log"; 16 15 import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 16 + import { createStore } from "solid-js/store"; 17 17 import { Motion, Presence } from "solid-motionone"; 18 18 import { PostCount } from "../shared/PostCount"; 19 19 import { EmbeddingsSettings } from "./EmbeddingsSettings"; ··· 23 23 24 24 const MODES: SearchMode[] = ["network", "keyword", "semantic", "hybrid"]; 25 25 26 + type SearchPanelState = { 27 + error: string | null; 28 + hasSearched: boolean; 29 + loading: boolean; 30 + mode: SearchMode; 31 + networkResults: NetworkSearchResult | null; 32 + query: string; 33 + resultCount: number; 34 + results: LocalPostResult[]; 35 + syncStatus: SyncStatus[]; 36 + }; 37 + 26 38 function ModeLabel(props: { mode: SearchMode }) { 27 39 return ( 28 40 <span class="flex items-center gap-1.5"> ··· 38 50 } 39 51 40 52 export function SearchPanel() { 53 + const preferences = useAppPreferences(); 41 54 const session = useAppSession(); 42 - const [mode, setMode] = createSignal<SearchMode>("network"); 43 - const [query, setQuery] = createSignal(""); 44 - const [results, setResults] = createSignal<LocalPostResult[]>([]); 45 - const [networkResults, setNetworkResults] = createSignal<NetworkSearchResult | null>(null); 46 - const [loading, setLoading] = createSignal(false); 47 - const [error, setError] = createSignal<string | null>(null); 48 - const [resultCount, setResultCount] = createSignal(0); 49 - const [hasSearched, setHasSearched] = createSignal(false); 50 - const [syncStatus, setSyncStatus] = createSignal<SyncStatus[]>([]); 51 - const [embeddingsConfig, setEmbeddingsConfig] = createSignal<EmbeddingsConfig | null>(null); 55 + const [search, setSearch] = createStore<SearchPanelState>({ 56 + error: null, 57 + hasSearched: false, 58 + loading: false, 59 + mode: "network", 60 + networkResults: null, 61 + query: "", 62 + resultCount: 0, 63 + results: [], 64 + syncStatus: [], 65 + }); 52 66 53 67 let searchInputRef: HTMLInputElement | undefined; 54 68 let debounceTimer: ReturnType<typeof setTimeout> | undefined; 55 69 56 - const isLocalMode = createMemo(() => mode() !== "network"); 57 - const semanticEnabled = createMemo(() => embeddingsConfig()?.enabled ?? true); 58 - const totalIndexedPosts = createMemo(() => syncStatus().reduce((sum, status) => sum + (status.postCount ?? 0), 0)); 70 + const isLocalMode = createMemo(() => search.mode !== "network"); 71 + const semanticEnabled = createMemo(() => preferences.embeddingsEnabled); 72 + const totalIndexedPosts = createMemo(() => 73 + search.syncStatus.reduce((sum, status) => sum + (status.postCount ?? 0), 0) 74 + ); 59 75 const hasLocalPosts = createMemo(() => totalIndexedPosts() > 0); 60 76 const lastSync = createMemo(() => { 61 - const timestamps = syncStatus().map((status) => status.lastSyncedAt).filter(Boolean) as string[]; 77 + const timestamps = search.syncStatus.map((status) => status.lastSyncedAt).filter(Boolean) as string[]; 62 78 if (timestamps.length === 0) { 63 79 return null; 64 80 } ··· 67 83 }); 68 84 const cycleModes = createMemo(() => MODES.filter((candidate) => candidate !== "semantic" || semanticEnabled())); 69 85 70 - async function loadEmbeddingsConfig() { 71 - try { 72 - setEmbeddingsConfig(await getEmbeddingsConfig()); 73 - } catch (err) { 74 - logger.error("failed to load embeddings config", { keyValues: { error: normalizeError(err) } }); 75 - } 76 - } 77 - 78 86 async function performSearch(searchQuery: string, searchMode: SearchMode) { 79 87 if (!searchQuery.trim()) { 80 88 clearResults(); ··· 82 90 } 83 91 84 92 if (searchMode === "semantic" && !semanticEnabled()) { 85 - setError("Semantic search is disabled. Re-enable embeddings to use this mode."); 86 - setHasSearched(true); 87 - setResults([]); 88 - setNetworkResults(null); 89 - setResultCount(0); 93 + setSearch({ 94 + error: "Semantic search is disabled. Re-enable embeddings to use this mode.", 95 + hasSearched: true, 96 + networkResults: null, 97 + resultCount: 0, 98 + results: [], 99 + }); 90 100 return; 91 101 } 92 102 93 - setLoading(true); 94 - setError(null); 103 + setSearch({ error: null, loading: true }); 95 104 96 105 try { 97 106 if (searchMode === "network") { 98 107 const response = await searchPostsNetwork(searchQuery, "top", 25); 99 - setNetworkResults(response); 100 - setResults([]); 101 - setResultCount(response.posts.length); 108 + setSearch({ hasSearched: true, networkResults: response, resultCount: response.posts.length, results: [] }); 102 109 } else { 103 110 const response = await searchPosts(searchQuery, searchMode, 50); 104 - setResults(response); 105 - setNetworkResults(null); 106 - setResultCount(response.length); 111 + setSearch({ hasSearched: true, networkResults: null, resultCount: response.length, results: response }); 107 112 } 108 - setHasSearched(true); 109 - } catch (err) { 110 - const errorMsg = normalizeError(err); 111 - setError(errorMsg); 112 - setResults([]); 113 - setNetworkResults(null); 114 - setResultCount(0); 115 - setHasSearched(true); 116 - logger.error("search failed", { keyValues: { query: searchQuery, mode: searchMode, error: errorMsg } }); 113 + } catch (error) { 114 + const errorMessage = normalizeError(error); 115 + setSearch({ error: errorMessage, hasSearched: true, networkResults: null, resultCount: 0, results: [] }); 116 + logger.error("search failed", { keyValues: { query: searchQuery, mode: searchMode, error: errorMessage } }); 117 117 } finally { 118 - setLoading(false); 118 + setSearch("loading", false); 119 119 } 120 120 } 121 121 122 122 function clearResults() { 123 - setResults([]); 124 - setNetworkResults(null); 125 - setResultCount(0); 126 - setError(null); 127 - setHasSearched(false); 123 + setSearch({ error: null, hasSearched: false, networkResults: null, resultCount: 0, results: [] }); 128 124 } 129 125 130 126 function handleInput(value: string) { 131 - setQuery(value); 127 + setSearch("query", value); 132 128 clearTimeout(debounceTimer); 133 129 debounceTimer = setTimeout(() => { 134 - void performSearch(value, mode()); 130 + void performSearch(value, search.mode); 135 131 }, 300); 136 132 } 137 133 ··· 140 136 return; 141 137 } 142 138 143 - setMode(newMode); 144 - if (query().trim()) { 145 - void performSearch(query(), newMode); 146 - } else { 147 - setError(null); 139 + setSearch("mode", newMode); 140 + if (search.query.trim()) { 141 + void performSearch(search.query, newMode); 142 + return; 148 143 } 144 + 145 + setSearch("error", null); 149 146 } 150 147 151 148 function cycleMode() { 152 149 const availableModes = cycleModes(); 153 - const currentIndex = availableModes.indexOf(mode()); 150 + const currentIndex = availableModes.indexOf(search.mode); 154 151 const nextIndex = (currentIndex + 1) % availableModes.length; 155 152 handleModeChange(availableModes[nextIndex] ?? availableModes[0] ?? "network"); 156 153 } 157 154 158 155 function clearSearch() { 159 - setQuery(""); 156 + setSearch("query", ""); 160 157 clearResults(); 161 158 searchInputRef?.focus(); 162 159 } ··· 165 162 if (event.key === "Tab" && !event.shiftKey && document.activeElement === searchInputRef) { 166 163 event.preventDefault(); 167 164 cycleMode(); 168 - } else if (event.key === "Escape" && query()) { 165 + return; 166 + } 167 + 168 + if (event.key === "Escape" && search.query) { 169 169 clearSearch(); 170 170 } 171 171 } ··· 182 182 183 183 onMount(() => { 184 184 document.addEventListener("keydown", handleGlobalKeyDown); 185 - void loadEmbeddingsConfig(); 186 185 187 186 onCleanup(() => { 188 187 document.removeEventListener("keydown", handleGlobalKeyDown); ··· 191 190 }); 192 191 193 192 createEffect(() => { 194 - if (mode() === "semantic" && !semanticEnabled()) { 195 - setMode("keyword"); 193 + if (search.mode === "semantic" && !semanticEnabled()) { 194 + setSearch("mode", "keyword"); 195 + if (search.query.trim()) { 196 + void performSearch(search.query, "keyword"); 197 + } 196 198 } 197 199 }); 198 200 ··· 200 202 <div class="grid min-h-0 gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"> 201 203 <section class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 202 204 <SearchHeader 203 - error={error()} 204 - hasSearched={hasSearched()} 205 - loading={loading()} 206 - mode={mode()} 207 - query={query()} 208 - resultCount={resultCount()} 209 - semanticEnabled={semanticEnabled()} 210 - totalIndexedPosts={totalIndexedPosts()} 211 - lastSync={lastSync()} 212 - onModeChange={handleModeChange} 213 - onQueryChange={handleInput} 205 + error={search.error} 206 + hasSearched={search.hasSearched} 214 207 inputRef={(element) => { 215 208 searchInputRef = element; 216 209 }} 210 + lastSync={lastSync()} 211 + loading={search.loading} 212 + mode={search.mode} 213 + onClear={clearSearch} 217 214 onKeyDown={handleKeyDown} 218 - onClear={clearSearch} /> 215 + onModeChange={handleModeChange} 216 + onQueryChange={handleInput} 217 + query={search.query} 218 + resultCount={search.resultCount} 219 + semanticEnabled={semanticEnabled()} 220 + totalIndexedPosts={totalIndexedPosts()} /> 219 221 220 222 <SearchViewport 221 - error={error()} 223 + error={search.error} 222 224 hasLocalPosts={hasLocalPosts()} 223 - hasSearched={hasSearched()} 225 + hasSearched={search.hasSearched} 224 226 isLocalMode={isLocalMode()} 225 - loading={loading()} 226 - localResults={results()} 227 - mode={mode()} 228 - networkResults={networkResults()} 229 - query={query()} /> 227 + loading={search.loading} 228 + localResults={search.results} 229 + networkResults={search.networkResults} 230 + query={search.query} /> 230 231 </section> 231 232 232 233 <aside class="grid content-start gap-4 overflow-y-auto"> 233 - <Show when={session.activeDid}>{(did) => <SyncStatusPanel did={did()} onStatusChange={setSyncStatus} />}</Show> 234 - <EmbeddingsSettings 235 - onConfigChange={(nextConfig) => { 236 - setEmbeddingsConfig(nextConfig); 237 - }} /> 234 + <Show when={session.activeDid}> 235 + {(did) => <SyncStatusPanel did={did()} onStatusChange={(status) => setSearch("syncStatus", status)} />} 236 + </Show> 237 + <EmbeddingsSettings /> 238 238 <SearchTipsCard /> 239 239 </aside> 240 240 </div> ··· 265 265 error={props.error} 266 266 inputRef={props.inputRef} 267 267 loading={props.loading} 268 + placeholder={props.mode === "network" 269 + ? "Search public posts across Bluesky..." 270 + : "Search your saved & liked posts..."} 268 271 query={props.query} 269 272 onClear={props.onClear} 270 273 onKeyDown={props.onKeyDown} ··· 282 285 283 286 <ResultMeta 284 287 hasSearched={props.hasSearched} 288 + lastSync={props.lastSync} 289 + mode={props.mode} 285 290 resultCount={props.resultCount} 286 - totalIndexedPosts={props.totalIndexedPosts} 287 - lastSync={props.lastSync} /> 291 + totalIndexedPosts={props.totalIndexedPosts} /> 288 292 </header> 289 293 ); 290 294 } 291 295 292 296 function ResultMeta( 293 - props: { hasSearched: boolean; lastSync: string | null; resultCount: number; totalIndexedPosts: number }, 297 + props: { 298 + hasSearched: boolean; 299 + lastSync: string | null; 300 + mode: SearchMode; 301 + resultCount: number; 302 + totalIndexedPosts: number; 303 + }, 294 304 ) { 295 305 return ( 296 306 <div class="flex items-center justify-between gap-4 border-t border-white/5 pt-3"> 297 307 <span class="text-sm text-on-surface-variant"> 298 308 <Show 299 309 when={props.hasSearched} 300 - fallback="Search your liked and bookmarked posts locally, or search the network."> 310 + fallback={props.mode === "network" 311 + ? "Search public posts across Bluesky or switch to your synced archive." 312 + : "Search your liked and bookmarked posts locally, or search the network."}> 301 313 <span> 302 314 Found <span class="font-medium text-on-surface">{props.resultCount}</span> results 303 315 </span> ··· 318 330 error: string | null; 319 331 inputRef: (el: HTMLInputElement) => void; 320 332 loading: boolean; 333 + placeholder: string; 321 334 query: string; 322 335 onClear: () => void; 323 336 onKeyDown: (event: KeyboardEvent) => void; ··· 335 348 ref={props.inputRef} 336 349 type="text" 337 350 value={props.query} 338 - placeholder="Search your saved & liked posts..." 351 + placeholder={props.placeholder} 339 352 class="w-full rounded-3xl border-0 bg-black/40 py-3.5 pl-12 pr-20 text-base text-on-surface placeholder:text-on-surface-variant/50 outline-none ring-1 ring-white/5 transition-all focus:ring-primary/50" 340 353 onInput={(event) => props.onQueryChange(event.currentTarget.value)} 341 354 onKeyDown={(event) => props.onKeyDown(event)} /> ··· 446 459 isLocalMode: boolean; 447 460 loading: boolean; 448 461 localResults: LocalPostResult[]; 449 - mode: SearchMode; 450 462 networkResults: NetworkSearchResult | null; 451 463 query: string; 452 464 }, ··· 468 480 hasLocalPosts: boolean; 469 481 hasSearched: boolean; 470 482 isLocalMode: boolean; 471 - loading: boolean; 472 483 localResults: LocalPostResult[]; 473 - mode: SearchMode; 474 484 networkResults: NetworkSearchResult | null; 475 485 query: string; 476 486 }, ··· 479 489 <Presence> 480 490 <Switch> 481 491 <Match when={props.error && props.query}> 482 - <EmptyStateView reason={props.isLocalMode && !props.hasLocalPosts ? "no-sync" : "no-results"} /> 492 + <EmptyStateView reason="error" scope={props.isLocalMode ? "local" : "network"} /> 483 493 </Match> 484 494 485 495 <Match when={props.isLocalMode && !props.hasLocalPosts}> 486 - <EmptyStateView reason="no-sync" /> 496 + <EmptyStateView reason="no-sync" scope="local" /> 487 497 </Match> 488 498 489 499 <Match when={!props.hasSearched && !props.query}> 490 - <EmptyStateView reason="initial" /> 500 + <EmptyStateView reason="initial" scope={props.isLocalMode ? "local" : "network"} /> 491 501 </Match> 492 502 493 503 <Match when={props.isLocalMode && props.localResults.length === 0}> 494 - <EmptyStateView reason="no-results" /> 504 + <EmptyStateView reason="no-results" scope="local" /> 495 505 </Match> 496 506 497 507 <Match when={!props.isLocalMode && props.networkResults?.posts.length === 0}> 498 - <EmptyStateView reason="no-results" /> 508 + <EmptyStateView reason="no-results" scope="network" /> 499 509 </Match> 500 510 501 511 <Match when={props.isLocalMode}> ··· 510 520 ); 511 521 } 512 522 513 - function EmptyStateView(props: { reason: "initial" | "no-results" | "no-sync" }) { 523 + function EmptyStateView(props: { reason: "error" | "initial" | "no-results" | "no-sync"; scope: "local" | "network" }) { 514 524 return ( 515 525 <Motion.div 516 526 class="grid place-items-center px-6 py-16" ··· 518 528 animate={{ opacity: 1 }} 519 529 exit={{ opacity: 0 }} 520 530 transition={{ duration: 0.15 }}> 521 - <SearchEmptyState reason={props.reason} /> 531 + <SearchEmptyState reason={props.reason} scope={props.scope} /> 522 532 </Motion.div> 523 533 ); 524 534 } ··· 603 613 return ( 604 614 <section class="panel-surface grid gap-3 p-5"> 605 615 <p class="m-0 text-sm font-medium text-on-surface">Search Tips</p> 606 - <div class="grid gap-2 grid-cols-2 text-xs text-on-surface-variant"> 616 + <div class="grid grid-cols-2 gap-2 text-xs text-on-surface-variant"> 607 617 <p class="m-0 flex items-center gap-2"> 608 618 <kbd class="rounded bg-white/10 px-1.5 py-0.5">/</kbd> 609 619 <span>Focus search from anywhere</span> ··· 612 622 <kbd class="rounded bg-white/10 px-1.5 py-0.5">Tab</kbd> 613 623 <span>Cycle search modes</span> 614 624 </p> 615 - <div class="flex flex-col items-start gap-1 col-span-2"> 616 - <div class="m-0 flex gap-2 items-start"> 625 + <div class="col-span-2 flex flex-col items-start gap-1"> 626 + <div class="m-0 flex items-start gap-2"> 617 627 <div>·</div> 618 628 <div>Use keyword mode for exact terms and hybrid mode for broader recall.</div> 619 629 </div> 620 - <div class="m-0 flex gap-2 items-start"> 630 + <div class="m-0 flex items-start gap-2"> 621 631 <div>·</div> 622 - <div>Network search queries public Bluesky posts without using your local index.</div> 632 + <div>Semantic mode follows the embeddings setting and model status shown above.</div> 623 633 </div> 624 634 </div> 625 635 </div>
+48
src/components/settings/SettingsAbout.tsx
··· 1 + import * as logger from "@tauri-apps/plugin-log"; 2 + import { openUrl } from "@tauri-apps/plugin-opener"; 3 + import { SettingsCard } from "./SettingsCard"; 4 + 5 + export function SettingsAbout() { 6 + return ( 7 + <SettingsCard icon="info" title="About"> 8 + <div class="grid gap-4"> 9 + <div class="flex items-center justify-between"> 10 + <div> 11 + <p class="text-sm font-medium text-on-surface">Version</p> 12 + <p class="text-xs text-on-surface-variant">0.1.0-alpha</p> 13 + </div> 14 + <button 15 + type="button" 16 + onClick={() => logger.info("checking for updates...")} 17 + class="inline-flex items-center justify-center gap-2 rounded-full border-0 bg-primary px-4 py-2 text-sm font-medium text-on-primary-fixed transition hover:opacity-90"> 18 + Check for updates 19 + </button> 20 + </div> 21 + <div class="flex items-center justify-between"> 22 + <div> 23 + <p class="text-sm font-medium text-on-surface">License</p> 24 + <p class="text-xs text-on-surface-variant">MIT License</p> 25 + </div> 26 + <button 27 + type="button" 28 + onClick={() => void openUrl("https://github.com/stormlightlabs/lazurite/blob/main/LICENSE")} 29 + class="rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5"> 30 + View license 31 + </button> 32 + </div> 33 + <div class="flex items-center justify-between"> 34 + <div> 35 + <p class="text-sm font-medium text-on-surface">Source code</p> 36 + <p class="text-xs text-on-surface-variant">github.com/stormlightlabs/lazurite</p> 37 + </div> 38 + <button 39 + type="button" 40 + onClick={() => void openUrl("https://github.com/stormlightlabs/lazurite")} 41 + class="rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5"> 42 + Open 43 + </button> 44 + </div> 45 + </div> 46 + </SettingsCard> 47 + ); 48 + }
+103
src/components/settings/SettingsAccount.tsx
··· 1 + import { useAppSession } from "$/contexts/app-session"; 2 + import type { AccountSummary } from "$/lib/types"; 3 + import { useNavigate } from "@solidjs/router"; 4 + import { For, Show } from "solid-js"; 5 + import { Icon } from "../shared/Icon"; 6 + import { SettingsCard } from "./SettingsCard"; 7 + 8 + function AccountItem(props: { account: AccountSummary; active: boolean; onRemove: () => void; onSwitch: () => void }) { 9 + return ( 10 + <div class="flex items-center justify-between rounded-xl bg-white/3 p-3 transition-colors hover:bg-white/5"> 11 + <div class="flex items-center gap-3"> 12 + <div class="relative"> 13 + <div class="h-10 w-10 overflow-hidden rounded-full"> 14 + <Show 15 + when={props.account.avatar} 16 + fallback={ 17 + <div class="flex h-full w-full items-center justify-center bg-surface-container-high text-sm font-medium text-on-surface"> 18 + {props.account.handle.slice(0, 2).toUpperCase()} 19 + </div> 20 + }> 21 + {(avatar) => <img src={avatar()} alt={props.account.handle} class="h-full w-full object-cover" />} 22 + </Show> 23 + </div> 24 + <Show when={props.active}> 25 + <div class="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-surface-container bg-green-500" /> 26 + </Show> 27 + </div> 28 + <div> 29 + <p class="text-sm font-medium text-on-surface">@{props.account.handle}</p> 30 + <p class="text-xs text-on-surface-variant">{props.account.did}</p> 31 + </div> 32 + </div> 33 + <div class="flex items-center gap-2"> 34 + <Show 35 + when={props.active} 36 + fallback={ 37 + <button 38 + type="button" 39 + onClick={() => props.onSwitch()} 40 + class="rounded-lg border border-white/20 px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-white/5"> 41 + Switch 42 + </button> 43 + }> 44 + <span class="rounded-full bg-primary/20 px-2 py-1 text-xs text-primary">Active</span> 45 + </Show> 46 + <button 47 + type="button" 48 + onClick={() => props.onRemove()} 49 + class="rounded-lg p-2 text-on-surface-variant transition hover:bg-white/5 hover:text-on-surface" 50 + title="Remove account"> 51 + <span class="flex items-center"> 52 + <Icon kind="close" class="text-sm" /> 53 + </span> 54 + </button> 55 + </div> 56 + </div> 57 + ); 58 + } 59 + 60 + export function AccountControl( 61 + props: { 62 + openConfirmation: ( 63 + config: { 64 + title: string; 65 + message: string; 66 + confirmText?: string; 67 + type?: "danger" | "default"; 68 + onConfirm: () => void; 69 + }, 70 + ) => void; 71 + }, 72 + ) { 73 + const session = useAppSession(); 74 + const navigate = useNavigate(); 75 + return ( 76 + <SettingsCard icon="user" title="Accounts"> 77 + <div class="grid gap-3"> 78 + <For each={session.accounts}> 79 + {(account) => ( 80 + <AccountItem 81 + account={account} 82 + active={account.active} 83 + onSwitch={() => void session.switchAccount(account.did)} 84 + onRemove={() => 85 + props.openConfirmation({ 86 + title: "Remove Account", 87 + message: 88 + `Are you sure you want to remove @${account.handle}? This will delete all local data for this account.`, 89 + type: "danger", 90 + onConfirm: () => void session.logout(account.did), 91 + })} /> 92 + )} 93 + </For> 94 + <button 95 + type="button" 96 + onClick={() => navigate("/auth")} 97 + class="inline-flex items-center justify-center gap-2 rounded-full border-0 bg-primary px-4 py-2 text-sm font-medium text-on-primary-fixed transition hover:opacity-90"> 98 + Add account 99 + </button> 100 + </div> 101 + </SettingsCard> 102 + ); 103 + }
+14
src/components/settings/SettingsCard.tsx
··· 1 + import { SettingsIcon, type SettingsIconKind } from "$/components/shared/Icon"; 2 + import type { ParentProps } from "solid-js"; 3 + 4 + export function SettingsCard(props: ParentProps & { icon: SettingsIconKind; title: string }) { 5 + return ( 6 + <section class="panel-surface grid gap-4 p-5"> 7 + <div class="flex items-center gap-3"> 8 + <SettingsIcon class="text-xl text-primary" kind={props.icon} /> 9 + <h2 class="text-lg font-medium text-on-surface">{props.title}</h2> 10 + </div> 11 + {props.children} 12 + </section> 13 + ); 14 + }
+134
src/components/settings/SettingsData.tsx
··· 1 + import { exportData } from "$/lib/api/settings"; 2 + import { formatBytes } from "$/lib/utils/text"; 3 + import { SettingsCard } from "./SettingsCard"; 4 + 5 + type SettingsDataProps = { 6 + cacheSize: { feedsBytes: number; embeddingsBytes: number; ftsBytes: number; totalBytes?: number } | null; 7 + handleClearCache: (scope: "feeds" | "embeddings" | "fts" | "all") => Promise<void>; 8 + handleResetApp: () => Promise<void>; 9 + openConfirmation: ( 10 + options: { 11 + title: string; 12 + message: string; 13 + confirmText?: string; 14 + type?: "default" | "danger"; 15 + onConfirm: () => void; 16 + }, 17 + ) => void; 18 + }; 19 + export function SettingsData(props: SettingsDataProps) { 20 + const cacheSize = () => props.cacheSize; 21 + 22 + return ( 23 + <SettingsCard icon="db" title="Data"> 24 + <div class="grid gap-4"> 25 + <div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> 26 + <div class="rounded-xl bg-black/30 p-4 text-center"> 27 + <p class="text-lg font-medium text-on-surface">{formatBytes(cacheSize()?.feedsBytes ?? 0)}</p> 28 + <p class="text-xs text-on-surface-variant">Feeds cache</p> 29 + </div> 30 + <div class="rounded-xl bg-black/30 p-4 text-center"> 31 + <p class="text-lg font-medium text-on-surface">{formatBytes(cacheSize()?.embeddingsBytes ?? 0)}</p> 32 + <p class="text-xs text-on-surface-variant">Embeddings</p> 33 + </div> 34 + <div class="rounded-xl bg-black/30 p-4 text-center"> 35 + <p class="text-lg font-medium text-on-surface">{formatBytes(cacheSize()?.ftsBytes ?? 0)}</p> 36 + <p class="text-xs text-on-surface-variant">Search index</p> 37 + </div> 38 + <div class="rounded-xl bg-black/30 p-4 text-center"> 39 + <p class="text-lg font-medium text-on-surface">{formatBytes(cacheSize()?.totalBytes ?? 0)}</p> 40 + <p class="text-xs text-on-surface-variant">Total local data</p> 41 + </div> 42 + </div> 43 + <div class="flex gap-4"> 44 + <button 45 + type="button" 46 + onClick={() => void props.handleClearCache("feeds")} 47 + class="flex-1 rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5"> 48 + Clear feeds 49 + </button> 50 + <button 51 + type="button" 52 + onClick={() => void props.handleClearCache("embeddings")} 53 + class="flex-1 rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5"> 54 + Clear embeddings 55 + </button> 56 + <button 57 + type="button" 58 + onClick={() => void props.handleClearCache("fts")} 59 + class="flex-1 rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5"> 60 + Clear search index 61 + </button> 62 + <button 63 + type="button" 64 + onClick={() => 65 + props.openConfirmation({ 66 + title: "Clear All Cache", 67 + message: 68 + "This will delete all cached data including feeds, embeddings, and search index. This action cannot be undone.", 69 + type: "danger", 70 + onConfirm: () => void props.handleClearCache("all"), 71 + })} 72 + class="flex-1 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-sm font-medium text-red-400 transition hover:bg-red-500/20"> 73 + Clear all 74 + </button> 75 + </div> 76 + <ExportControl /> 77 + <ResetControl handleResetApp={props.handleResetApp} openConfirmation={props.openConfirmation} /> 78 + </div> 79 + </SettingsCard> 80 + ); 81 + } 82 + 83 + function ExportControl() { 84 + return ( 85 + <div class="border-t border-white/10 pt-4"> 86 + <div class="flex items-center justify-between"> 87 + <div> 88 + <p class="text-sm font-medium text-on-surface">Export your data</p> 89 + <p class="text-xs text-on-surface-variant">Download all your data as JSON or CSV</p> 90 + </div> 91 + <div class="flex gap-2"> 92 + <button 93 + type="button" 94 + onClick={() => void exportData("json")} 95 + class="rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5"> 96 + JSON 97 + </button> 98 + <button 99 + type="button" 100 + onClick={() => void exportData("csv")} 101 + class="rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5"> 102 + CSV 103 + </button> 104 + </div> 105 + </div> 106 + </div> 107 + ); 108 + } 109 + 110 + function ResetControl(props: Pick<SettingsDataProps, "handleResetApp" | "openConfirmation">) { 111 + return ( 112 + <div class="border-t border-white/10 pt-4"> 113 + <div class="flex items-center justify-between"> 114 + <div> 115 + <p class="text-sm font-medium text-red-400">Reset application</p> 116 + <p class="text-xs text-on-surface-variant">Remove all data and reset to defaults</p> 117 + </div> 118 + <button 119 + type="button" 120 + onClick={() => 121 + props.openConfirmation({ 122 + title: "Reset Application", 123 + message: "This will delete ALL data including accounts, settings, and cache. Type RESET to confirm.", 124 + confirmText: "RESET", 125 + type: "danger", 126 + onConfirm: () => void props.handleResetApp(), 127 + })} 128 + class="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-sm font-medium text-red-400 transition hover:bg-red-500/20"> 129 + Reset... 130 + </button> 131 + </div> 132 + </div> 133 + ); 134 + }
+97
src/components/settings/SettingsLogs.tsx
··· 1 + import type { LogEntry, LogLevelFilter } from "$/lib/types"; 2 + import { For, Show } from "solid-js"; 3 + import { Motion, Presence } from "solid-motionone"; 4 + import { Icon } from "../shared/Icon"; 5 + import { SegmentedControl } from "../shared/SegmentedControl"; 6 + import { SettingsCard } from "./SettingsCard"; 7 + 8 + const LOG_LEVEL_OPTIONS: { value: LogLevelFilter; label: string }[] = [ 9 + { value: "all", label: "All" }, 10 + { value: "info", label: "Info" }, 11 + { value: "warn", label: "Warn" }, 12 + { value: "error", label: "Error" }, 13 + ]; 14 + 15 + type SettingsLogsProps = { 16 + expanded: boolean; 17 + logLevel: LogLevelFilter; 18 + handleChange: (level: LogLevelFilter) => void; 19 + logs: LogEntry[]; 20 + loadLogs: () => Promise<void>; 21 + expand: (expanded: boolean) => void; 22 + }; 23 + 24 + export function SettingsLogs(props: SettingsLogsProps) { 25 + const expanded = () => props.expanded; 26 + const level = () => props.logLevel; 27 + const logs = () => props.logs; 28 + return ( 29 + <SettingsCard icon="computer" title="Logs"> 30 + <div class="grid gap-3"> 31 + <div class="flex items-center justify-between"> 32 + <SegmentedControl options={LOG_LEVEL_OPTIONS} value={level()} onChange={(v) => props.handleChange(v)} /> 33 + <div class="flex gap-2"> 34 + <button 35 + type="button" 36 + onClick={() => { 37 + void navigator.clipboard.writeText( 38 + logs().map((l) => `[${l.timestamp}] ${l.level}: ${l.message}`).join("\n"), 39 + ); 40 + }} 41 + class="rounded-lg border border-white/20 px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-white/5"> 42 + Copy all 43 + </button> 44 + <button 45 + type="button" 46 + onClick={() => void props.loadLogs()} 47 + class="rounded-lg border border-white/20 px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-white/5"> 48 + Refresh 49 + </button> 50 + </div> 51 + </div> 52 + <button 53 + type="button" 54 + onClick={() => props.expand(!expanded())} 55 + class="flex items-center justify-between rounded-lg bg-black/40 px-4 py-2 text-sm text-on-surface transition hover:bg-black/50"> 56 + <span>{expanded() ? "Collapse" : "Expand"} log viewer</span> 57 + <Icon kind={expanded() ? "close" : "menu"} class="text-xs" /> 58 + </button> 59 + <Presence> 60 + <Show when={expanded()}> 61 + <LogDisplay logs={logs()} /> 62 + </Show> 63 + </Presence> 64 + </div> 65 + </SettingsCard> 66 + ); 67 + } 68 + 69 + function LogDisplay(props: { logs: LogEntry[] }) { 70 + return ( 71 + <Motion.div 72 + class="overflow-hidden" 73 + initial={{ height: 0 }} 74 + animate={{ height: "auto" }} 75 + exit={{ height: 0 }} 76 + transition={{ duration: 0.2 }}> 77 + <div class="max-h-48 overflow-y-auto rounded-xl bg-black/50 p-4 font-mono text-xs"> 78 + <For each={props.logs} fallback={<p class="text-on-surface-variant">No log entries found</p>}> 79 + {(log) => ( 80 + <div class="mb-1 flex gap-3"> 81 + <span class="text-on-surface-variant">{log.timestamp?.split("T")[1]?.slice(0, 8) ?? "--:--:--"}</span> 82 + <span 83 + classList={{ 84 + "text-primary": log.level === "INFO", 85 + "text-yellow-400": log.level === "WARN", 86 + "text-red-400": log.level === "ERROR", 87 + }}> 88 + {log.level} 89 + </span> 90 + <span class="text-on-secondary-container">{log.message}</span> 91 + </div> 92 + )} 93 + </For> 94 + </div> 95 + </Motion.div> 96 + ); 97 + }
+36
src/components/settings/SettingsNotification.tsx
··· 1 + import type { AppSettings } from "$/lib/types"; 2 + import { SettingsCard } from "./SettingsCard"; 3 + import { ToggleRow } from "./SettingsToggleRow"; 4 + 5 + export function NotificationsControl( 6 + props: { 7 + handleUpdateSetting?: (key: keyof AppSettings, value: string | boolean) => void; 8 + settings?: AppSettings | null; 9 + }, 10 + ) { 11 + const notificationsDesktop = () => props.settings?.notificationsDesktop ?? true; 12 + const notificationsBadge = () => props.settings?.notificationsBadge ?? true; 13 + const notificationsSound = () => props.settings?.notificationsSound ?? false; 14 + 15 + return ( 16 + <SettingsCard icon="notifications" title="Notifications"> 17 + <div class="grid gap-4"> 18 + <ToggleRow 19 + label="Desktop notifications" 20 + description="Show OS-level notification popups" 21 + checked={notificationsDesktop()} 22 + onChange={() => void props.handleUpdateSetting?.("notificationsDesktop", !notificationsDesktop())} /> 23 + <ToggleRow 24 + label="Badge count" 25 + description="Show unread count on dock icon" 26 + checked={notificationsBadge()} 27 + onChange={() => void props.handleUpdateSetting?.("notificationsBadge", !notificationsBadge())} /> 28 + <ToggleRow 29 + label="Sound" 30 + description="Play sound for new notifications" 31 + checked={notificationsSound()} 32 + onChange={() => void props.handleUpdateSetting?.("notificationsSound", !notificationsSound())} /> 33 + </div> 34 + </SettingsCard> 35 + ); 36 + }
+277
src/components/settings/SettingsPanel.test.tsx
··· 1 + import { AppTestProviders } from "$/test/providers"; 2 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 3 + import { beforeEach, describe, expect, it, vi } from "vitest"; 4 + import { SettingsPanel } from "./SettingsPanel"; 5 + 6 + const getSettingsMock = vi.hoisted(() => vi.fn()); 7 + const updateSettingMock = vi.hoisted(() => vi.fn()); 8 + const getCacheSizeMock = vi.hoisted(() => vi.fn()); 9 + const clearCacheMock = vi.hoisted(() => vi.fn()); 10 + const exportDataMock = vi.hoisted(() => vi.fn()); 11 + const resetAppMock = vi.hoisted(() => vi.fn()); 12 + const getLogEntriesMock = vi.hoisted(() => vi.fn()); 13 + const navigateMock = vi.hoisted(() => vi.fn()); 14 + const infoMock = vi.hoisted(() => vi.fn()); 15 + 16 + const DEFAULT_EMBEDDINGS_CONFIG = { 17 + enabled: true, 18 + modelName: "nomic-embed-text-v1.5", 19 + dimensions: 768, 20 + downloaded: true, 21 + downloadActive: false, 22 + }; 23 + 24 + vi.mock( 25 + "$/lib/api/settings", 26 + () => ({ 27 + getSettings: getSettingsMock, 28 + updateSetting: updateSettingMock, 29 + getCacheSize: getCacheSizeMock, 30 + clearCache: clearCacheMock, 31 + exportData: exportDataMock, 32 + resetApp: resetAppMock, 33 + getLogEntries: getLogEntriesMock, 34 + }), 35 + ); 36 + 37 + vi.mock("@solidjs/router", () => ({ useNavigate: () => navigateMock })); 38 + 39 + vi.mock("@tauri-apps/plugin-log", () => ({ info: infoMock })); 40 + 41 + function createMockSettings(overrides = {}) { 42 + return { 43 + theme: "auto", 44 + timelineRefreshSecs: 60, 45 + notificationsDesktop: true, 46 + notificationsBadge: true, 47 + notificationsSound: false, 48 + embeddingsEnabled: true, 49 + constellationUrl: "https://constellation.microcosm.blue", 50 + spacedustUrl: "https://spacedust.microcosm.blue", 51 + spacedustInstant: false, 52 + spacedustEnabled: false, 53 + globalShortcut: "Ctrl+Shift+N", 54 + ...overrides, 55 + }; 56 + } 57 + 58 + function createMockCacheSize(overrides = {}) { 59 + return { 60 + feedsBytes: 1024 * 1024 * 100, 61 + embeddingsBytes: 1024 * 1024 * 200, 62 + ftsBytes: 1024 * 1024 * 50, 63 + totalBytes: 1024 * 1024 * 350, 64 + ...overrides, 65 + }; 66 + } 67 + 68 + function createMockLogEntry(level = "INFO", message = "Test log message") { 69 + return { timestamp: new Date().toISOString(), level, target: "test", message }; 70 + } 71 + 72 + function renderSettingsPanel( 73 + options: { preferences?: Record<string, unknown>; session?: Record<string, unknown> } = {}, 74 + ) { 75 + render(() => ( 76 + <AppTestProviders 77 + preferences={{ 78 + settings: createMockSettings(), 79 + embeddingsConfig: DEFAULT_EMBEDDINGS_CONFIG, 80 + updateSetting: updateSettingMock, 81 + ...options.preferences, 82 + }} 83 + session={options.session}> 84 + <SettingsPanel /> 85 + </AppTestProviders> 86 + )); 87 + } 88 + 89 + describe("SettingsPanel", () => { 90 + beforeEach(() => { 91 + vi.resetAllMocks(); 92 + getSettingsMock.mockResolvedValue(createMockSettings()); 93 + getCacheSizeMock.mockResolvedValue(createMockCacheSize()); 94 + getLogEntriesMock.mockResolvedValue([createMockLogEntry()]); 95 + updateSettingMock.mockResolvedValue(void 0); 96 + clearCacheMock.mockResolvedValue(void 0); 97 + exportDataMock.mockResolvedValue(void 0); 98 + resetAppMock.mockResolvedValue(void 0); 99 + }); 100 + 101 + it("loads and displays settings", async () => { 102 + renderSettingsPanel(); 103 + 104 + expect(await screen.findByText("Settings")).toBeInTheDocument(); 105 + expect(await screen.findByText("Appearance")).toBeInTheDocument(); 106 + expect(await screen.findByText("Timeline")).toBeInTheDocument(); 107 + expect(await screen.findByText("Notifications")).toBeInTheDocument(); 108 + expect(await screen.findByText("Accounts")).toBeInTheDocument(); 109 + expect(await screen.findByText("Services")).toBeInTheDocument(); 110 + expect(await screen.findByText("Data")).toBeInTheDocument(); 111 + expect(await screen.findByText("Logs")).toBeInTheDocument(); 112 + expect(await screen.findByText("About")).toBeInTheDocument(); 113 + }); 114 + 115 + it("displays cache size information", async () => { 116 + renderSettingsPanel(); 117 + 118 + await screen.findByText("Settings"); 119 + expect(await screen.findByText("100 MB")).toBeInTheDocument(); 120 + expect(await screen.findByText("Feeds cache")).toBeInTheDocument(); 121 + }); 122 + 123 + it("allows toggling desktop notifications", async () => { 124 + renderSettingsPanel(); 125 + 126 + await screen.findByText("Settings"); 127 + const toggle = await screen.findByRole("switch", { name: /desktop notifications/i }); 128 + 129 + fireEvent.click(toggle); 130 + await waitFor(() => expect(updateSettingMock).toHaveBeenCalledWith("notificationsDesktop", false)); 131 + }); 132 + 133 + it("allows toggling badge count", async () => { 134 + renderSettingsPanel(); 135 + 136 + await screen.findByText("Settings"); 137 + const toggle = await screen.findByRole("switch", { name: /badge count/i }); 138 + 139 + fireEvent.click(toggle); 140 + await waitFor(() => expect(updateSettingMock).toHaveBeenCalledWith("notificationsBadge", false)); 141 + }); 142 + 143 + it("allows changing theme", async () => { 144 + renderSettingsPanel(); 145 + 146 + await screen.findByText("Settings"); 147 + const darkButton = await screen.findByRole("button", { name: /dark/i }); 148 + 149 + fireEvent.click(darkButton); 150 + await waitFor(() => expect(updateSettingMock).toHaveBeenCalledWith("theme", "dark")); 151 + }); 152 + 153 + it("allows changing refresh interval", async () => { 154 + renderSettingsPanel(); 155 + 156 + await screen.findByText("Settings"); 157 + const manualButton = await screen.findByRole("button", { name: /manual/i }); 158 + 159 + fireEvent.click(manualButton); 160 + await waitFor(() => expect(updateSettingMock).toHaveBeenCalledWith("timelineRefreshSecs", 0)); 161 + }); 162 + 163 + it("allows clearing feeds cache", async () => { 164 + renderSettingsPanel(); 165 + 166 + await screen.findByText("Settings"); 167 + const clearFeedsButton = await screen.findByRole("button", { name: /clear feeds/i }); 168 + 169 + fireEvent.click(clearFeedsButton); 170 + await waitFor(() => expect(clearCacheMock).toHaveBeenCalledWith("feeds")); 171 + }); 172 + 173 + it("shows confirmation modal before clearing all cache", async () => { 174 + renderSettingsPanel(); 175 + 176 + await screen.findByText("Settings"); 177 + const clearAllButton = await screen.findByRole("button", { name: /clear all/i }); 178 + 179 + fireEvent.click(clearAllButton); 180 + expect(await screen.findByText("Clear All Cache")).toBeInTheDocument(); 181 + }); 182 + 183 + it("allows exporting data as JSON", async () => { 184 + renderSettingsPanel(); 185 + 186 + await screen.findByText("Settings"); 187 + const jsonButton = await screen.findByRole("button", { name: /json/i }); 188 + 189 + fireEvent.click(jsonButton); 190 + await waitFor(() => expect(exportDataMock).toHaveBeenCalledWith("json")); 191 + }); 192 + 193 + it("allows exporting data as CSV", async () => { 194 + renderSettingsPanel(); 195 + 196 + await screen.findByText("Settings"); 197 + const csvButton = await screen.findByRole("button", { name: /csv/i }); 198 + 199 + fireEvent.click(csvButton); 200 + await waitFor(() => expect(exportDataMock).toHaveBeenCalledWith("csv")); 201 + }); 202 + 203 + it("shows confirmation modal with RESET text for app reset", async () => { 204 + renderSettingsPanel(); 205 + 206 + await screen.findByText("Settings"); 207 + const resetButton = await screen.findByRole("button", { name: /reset\.\.\./i }); 208 + 209 + fireEvent.click(resetButton); 210 + expect(await screen.findByText("Reset Application")).toBeInTheDocument(); 211 + expect(await screen.findByPlaceholderText(/type "reset" to confirm/i)).toBeInTheDocument(); 212 + }); 213 + 214 + it("navigates back when close button is clicked", async () => { 215 + renderSettingsPanel(); 216 + 217 + await screen.findByText("Settings"); 218 + const closeButton = await screen.findByRole("button", { name: /close settings/i }); 219 + 220 + fireEvent.click(closeButton); 221 + await waitFor(() => expect(navigateMock).toHaveBeenCalledWith(-1)); 222 + }); 223 + 224 + it("expands and collapses log viewer", async () => { 225 + renderSettingsPanel(); 226 + 227 + await screen.findByText("Settings"); 228 + const expandButton = await screen.findByRole("button", { name: /expand log viewer/i }); 229 + 230 + fireEvent.click(expandButton); 231 + expect(await screen.findByRole("button", { name: /collapse log viewer/i })).toBeInTheDocument(); 232 + }); 233 + 234 + it("copies logs to clipboard", async () => { 235 + const clipboardWriteText = vi.fn().mockResolvedValue(void 0); 236 + Object.assign(navigator, { clipboard: { writeText: clipboardWriteText } }); 237 + 238 + renderSettingsPanel(); 239 + 240 + await screen.findByText("Settings"); 241 + const copyButton = await screen.findByRole("button", { name: /copy all/i }); 242 + 243 + fireEvent.click(copyButton); 244 + await waitFor(() => expect(clipboardWriteText).toHaveBeenCalled()); 245 + }); 246 + 247 + it("filters logs by level", async () => { 248 + getLogEntriesMock.mockResolvedValue([ 249 + createMockLogEntry("INFO", "Info message"), 250 + createMockLogEntry("WARN", "Warning message"), 251 + createMockLogEntry("ERROR", "Error message"), 252 + ]); 253 + 254 + renderSettingsPanel(); 255 + 256 + await screen.findByText("Settings"); 257 + const warnButton = await screen.findByRole("button", { name: /warn/i }); 258 + 259 + fireEvent.click(warnButton); 260 + await waitFor(() => expect(getLogEntriesMock).toHaveBeenCalledWith(100, "warn")); 261 + }); 262 + 263 + it("displays accounts from session", async () => { 264 + const accounts = [{ 265 + did: "did:plc:abc123", 266 + handle: "user.bsky.social", 267 + pdsUrl: "https://bsky.social", 268 + active: true, 269 + }, { did: "did:plc:xyz789", handle: "alt.bsky.social", pdsUrl: "https://bsky.social", active: false }]; 270 + 271 + renderSettingsPanel({ session: { accounts } }); 272 + 273 + await screen.findByText("Settings"); 274 + expect(await screen.findByText("@user.bsky.social")).toBeInTheDocument(); 275 + expect(await screen.findByText("@alt.bsky.social")).toBeInTheDocument(); 276 + }); 277 + });
+321
src/components/settings/SettingsPanel.tsx
··· 1 + import { EmbeddingsSettings } from "$/components/search/EmbeddingsSettings"; 2 + import { useAppPreferences } from "$/contexts/app-preferences"; 3 + import { clearCache, getCacheSize, getLogEntries, resetApp } from "$/lib/api/settings"; 4 + import type { 5 + AppSettings, 6 + CacheClearScope, 7 + CacheSize, 8 + LogEntry, 9 + LogLevelFilter, 10 + RefreshInterval, 11 + Theme, 12 + } from "$/lib/types"; 13 + import { normalizeError } from "$/lib/utils/text"; 14 + import { useNavigate } from "@solidjs/router"; 15 + import * as logger from "@tauri-apps/plugin-log"; 16 + import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 17 + import { createStore } from "solid-js/store"; 18 + import { Motion, Presence } from "solid-motionone"; 19 + import { Icon } from "../shared/Icon"; 20 + import { SettingsAbout } from "./SettingsAbout"; 21 + import { AccountControl } from "./SettingsAccount"; 22 + import { SettingsData } from "./SettingsData"; 23 + import { SettingsLogs } from "./SettingsLogs"; 24 + import { NotificationsControl } from "./SettingsNotification"; 25 + import { SettingsService } from "./SettingsService"; 26 + import { AppearanceControl } from "./SettingsTheme"; 27 + import { TimelineControl } from "./SettingsTimeline"; 28 + 29 + type SettingsPanelState = { 30 + cacheSize: CacheSize | null; 31 + logLevel: LogLevelFilter; 32 + logs: LogEntry[]; 33 + logsExpanded: boolean; 34 + modalConfig: { 35 + title: string; 36 + message: string; 37 + confirmText?: string; 38 + type?: "danger" | "default"; 39 + onConfirm: () => void; 40 + } | null; 41 + modalOpen: boolean; 42 + }; 43 + 44 + function ConfirmationModal( 45 + props: { 46 + confirmText?: string; 47 + isOpen: boolean; 48 + message: string; 49 + onCancel: () => void; 50 + onConfirm: () => void; 51 + title: string; 52 + type?: "danger" | "default"; 53 + }, 54 + ) { 55 + const [inputValue, setInputValue] = createSignal(""); 56 + const requiresConfirmText = () => props.confirmText !== undefined; 57 + const canConfirm = () => !requiresConfirmText() || inputValue() === props.confirmText; 58 + 59 + return ( 60 + <Presence> 61 + <Show when={props.isOpen}> 62 + <Motion.div 63 + class="fixed inset-0 z-50 flex items-center justify-center bg-surface-container-highest/70 p-4 backdrop-blur-xl" 64 + initial={{ opacity: 0 }} 65 + animate={{ opacity: 1 }} 66 + exit={{ opacity: 0 }} 67 + transition={{ duration: 0.2 }}> 68 + <Motion.div 69 + class="w-full max-w-md rounded-2xl bg-surface-container p-6 shadow-2xl" 70 + initial={{ scale: 0.95, opacity: 0 }} 71 + animate={{ scale: 1, opacity: 1 }} 72 + exit={{ scale: 0.95, opacity: 0 }} 73 + transition={{ duration: 0.2 }}> 74 + <h3 class="mb-2 text-lg font-semibold text-on-surface">{props.title}</h3> 75 + <p class="mb-4 text-sm text-on-surface-variant">{props.message}</p> 76 + 77 + <ConfirmTextInput 78 + required={requiresConfirmText()} 79 + value={inputValue()} 80 + handleInput={setInputValue} 81 + confirmText={props.confirmText ?? ""} /> 82 + <Actions 83 + confirmable={canConfirm()} 84 + type={props.type} 85 + onConfirm={props.onConfirm} 86 + onCancel={props.onCancel} /> 87 + </Motion.div> 88 + </Motion.div> 89 + </Show> 90 + </Presence> 91 + ); 92 + } 93 + 94 + function ConfirmTextInput( 95 + props: { required: boolean; value: string; handleInput: (value: string) => void; confirmText: string }, 96 + ) { 97 + return ( 98 + <Show when={props.required}> 99 + <input 100 + type="text" 101 + value={props.value} 102 + onInput={(e) => props.handleInput(e.currentTarget.value)} 103 + placeholder={`Type "${props.confirmText}" to confirm`} 104 + class="mb-4 w-full rounded-lg border border-white/10 bg-black/40 px-4 py-2 text-sm text-on-surface outline-none transition focus:border-primary/50" /> 105 + </Show> 106 + ); 107 + } 108 + 109 + function Actions( 110 + props: { confirmable: boolean; type?: "danger" | "default"; onConfirm: () => void; onCancel: () => void }, 111 + ) { 112 + return ( 113 + <div class="flex justify-end gap-2"> 114 + <button 115 + type="button" 116 + onClick={() => props.onCancel()} 117 + class="rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5"> 118 + Cancel 119 + </button> 120 + <button 121 + type="button" 122 + disabled={!props.confirmable} 123 + onClick={() => props.onConfirm()} 124 + class="rounded-lg px-4 py-2 text-sm font-medium text-on-primary-fixed transition disabled:cursor-not-allowed disabled:opacity-50" 125 + classList={{ 126 + "bg-red-500 hover:bg-red-600": props.type === "danger", 127 + "bg-primary hover:bg-primary-dim": props.type !== "danger", 128 + }}> 129 + Confirm 130 + </button> 131 + </div> 132 + ); 133 + } 134 + 135 + function SettingsSkeleton() { 136 + return ( 137 + <div class="grid gap-8"> 138 + <For each={Array.from({ length: 5 })}> 139 + {() => ( 140 + <div class="panel-surface animate-pulse p-5"> 141 + <div class="mb-4 flex items-center gap-3"> 142 + <div class="h-6 w-6 rounded-full bg-white/5" /> 143 + <div class="h-5 w-24 rounded-full bg-white/5" /> 144 + </div> 145 + <div class="grid gap-3"> 146 + <div class="h-10 rounded-lg bg-white/5" /> 147 + <div class="h-10 rounded-lg bg-white/5" /> 148 + </div> 149 + </div> 150 + )} 151 + </For> 152 + </div> 153 + ); 154 + } 155 + 156 + export function SettingsPanel() { 157 + const preferences = useAppPreferences(); 158 + const navigate = useNavigate(); 159 + const [panel, setPanel] = createStore<SettingsPanelState>({ 160 + cacheSize: null, 161 + logLevel: "all", 162 + logs: [], 163 + logsExpanded: false, 164 + modalConfig: null, 165 + modalOpen: false, 166 + }); 167 + 168 + const settings = () => preferences.settings; 169 + const loading = () => preferences.settingsLoading; 170 + 171 + async function loadCacheSize() { 172 + try { 173 + setPanel("cacheSize", await getCacheSize()); 174 + } catch (err) { 175 + logger.error("failed to load cache size", { keyValues: { error: normalizeError(err) } }); 176 + } 177 + } 178 + 179 + async function loadLogs(level = panel.logLevel) { 180 + try { 181 + setPanel("logs", await getLogEntries(100, level)); 182 + } catch (err) { 183 + logger.error("failed to load logs", { keyValues: { error: normalizeError(err) } }); 184 + } 185 + } 186 + 187 + async function handleUpdateSetting(key: keyof AppSettings, value: string | boolean | number) { 188 + await preferences.updateSetting(key, value); 189 + } 190 + 191 + async function handleClearCache(scope: CacheClearScope) { 192 + try { 193 + await clearCache(scope); 194 + await loadCacheSize(); 195 + } catch (err) { 196 + logger.error("failed to clear cache", { keyValues: { scope, error: normalizeError(err) } }); 197 + } 198 + } 199 + 200 + async function handleResetApp() { 201 + try { 202 + await resetApp(); 203 + navigate("/auth"); 204 + } catch (err) { 205 + logger.error("failed to reset app", { keyValues: { error: normalizeError(err) } }); 206 + } 207 + } 208 + 209 + function openConfirmation( 210 + config: { 211 + title: string; 212 + message: string; 213 + confirmText?: string; 214 + type?: "danger" | "default"; 215 + onConfirm: () => void; 216 + }, 217 + ) { 218 + setPanel("modalConfig", config); 219 + setPanel("modalOpen", true); 220 + } 221 + 222 + onMount(() => { 223 + void loadCacheSize(); 224 + globalThis.addEventListener("keydown", handleKeyDown); 225 + onCleanup(() => globalThis.removeEventListener("keydown", handleKeyDown)); 226 + }); 227 + 228 + const handleKeyDown = (e: KeyboardEvent) => { 229 + if (e.key === "Escape" && panel.modalOpen) { 230 + setPanel("modalOpen", false); 231 + } 232 + }; 233 + 234 + createEffect(() => { 235 + void loadLogs(panel.logLevel); 236 + }); 237 + 238 + const currentTheme = createMemo((): Theme => { 239 + const s = settings(); 240 + if (!s) return "auto"; 241 + const t = s.theme; 242 + return t === "light" || t === "dark" || t === "auto" ? t : "auto"; 243 + }); 244 + 245 + const currentRefresh = createMemo((): RefreshInterval => { 246 + const s = settings(); 247 + if (!s) return 60; 248 + const secs = s.timelineRefreshSecs; 249 + return [30, 60, 120, 300, 0].includes(secs) ? (secs as RefreshInterval) : 60; 250 + }); 251 + 252 + return ( 253 + <article class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 254 + <header class="grid gap-5 px-6 pb-4 pt-6"> 255 + <div class="flex items-center justify-between gap-4"> 256 + <div class="grid gap-1"> 257 + <p class="overline-copy text-xs text-on-surface-variant">Configuration</p> 258 + <h1 class="m-0 text-xl font-semibold tracking-tight text-on-surface">Settings</h1> 259 + </div> 260 + <button 261 + type="button" 262 + onClick={() => navigate(-1)} 263 + class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-surface-container-high text-on-surface-variant transition duration-150 hover:-translate-y-px hover:text-on-surface" 264 + title="Close settings"> 265 + <Icon kind="close" aria-hidden="true" class="text-lg" /> 266 + </button> 267 + </div> 268 + </header> 269 + 270 + <div class="min-h-0 overflow-y-auto px-6 pb-6"> 271 + <div class="mx-auto grid max-w-2xl gap-8"> 272 + <Show 273 + when={loading()} 274 + fallback={ 275 + <Presence> 276 + <Motion.div 277 + class="grid gap-8" 278 + initial={{ opacity: 0, y: 20 }} 279 + animate={{ opacity: 1, y: 0 }} 280 + transition={{ duration: 0.3 }}> 281 + <AppearanceControl currentTheme={currentTheme()} handleUpdateSetting={handleUpdateSetting} /> 282 + <TimelineControl currentRefresh={currentRefresh()} handleUpdateSetting={handleUpdateSetting} /> 283 + <NotificationsControl settings={settings()} handleUpdateSetting={handleUpdateSetting} /> 284 + <EmbeddingsSettings /> 285 + <AccountControl openConfirmation={openConfirmation} /> 286 + <SettingsService settings={settings()} handleUpdateSetting={handleUpdateSetting} /> 287 + <SettingsData 288 + cacheSize={panel.cacheSize} 289 + handleClearCache={handleClearCache} 290 + openConfirmation={openConfirmation} 291 + handleResetApp={handleResetApp} /> 292 + <SettingsLogs 293 + expanded={panel.logsExpanded} 294 + logLevel={panel.logLevel} 295 + handleChange={(level) => setPanel("logLevel", level)} 296 + logs={panel.logs} 297 + loadLogs={loadLogs} 298 + expand={(expanded) => setPanel("logsExpanded", expanded)} /> 299 + <SettingsAbout /> 300 + </Motion.div> 301 + </Presence> 302 + }> 303 + <SettingsSkeleton /> 304 + </Show> 305 + </div> 306 + </div> 307 + 308 + <ConfirmationModal 309 + isOpen={panel.modalOpen} 310 + title={panel.modalConfig?.title ?? ""} 311 + message={panel.modalConfig?.message ?? ""} 312 + confirmText={panel.modalConfig?.confirmText} 313 + type={panel.modalConfig?.type} 314 + onCancel={() => setPanel("modalOpen", false)} 315 + onConfirm={() => { 316 + panel.modalConfig?.onConfirm(); 317 + setPanel("modalOpen", false); 318 + }} /> 319 + </article> 320 + ); 321 + }
+51
src/components/settings/SettingsService.tsx
··· 1 + import type { AppSettings } from "$/lib/types"; 2 + import { SettingsCard } from "./SettingsCard"; 3 + import { ToggleRow } from "./SettingsToggleRow"; 4 + 5 + export function SettingsService( 6 + props: { 7 + settings: AppSettings | null; 8 + handleUpdateSetting: (key: keyof AppSettings, value: string | boolean | number) => Promise<void>; 9 + }, 10 + ) { 11 + const constellationUrl = () => props.settings?.constellationUrl ?? "https://constellation.microcosm.blue"; 12 + const spacedustUrl = () => props.settings?.spacedustUrl ?? "https://spacedust.microcosm.blue"; 13 + const spacedustEnabled = () => props.settings?.spacedustEnabled ?? false; 14 + const spacedustInstant = () => props.settings?.spacedustInstant ?? false; 15 + return ( 16 + <SettingsCard icon="services" title="Services"> 17 + <div class="grid gap-4"> 18 + <div> 19 + <label class="mb-2 block text-sm font-medium text-on-surface">Constellation URL</label> 20 + <div class="flex gap-2"> 21 + <input 22 + type="text" 23 + value={constellationUrl()} 24 + onChange={(e) => void props.handleUpdateSetting("constellationUrl", e.currentTarget.value)} 25 + class="flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-on-surface outline-none transition focus:border-primary/50" /> 26 + </div> 27 + </div> 28 + <div> 29 + <label class="mb-2 block text-sm font-medium text-on-surface">Spacedust URL</label> 30 + <div class="flex gap-2"> 31 + <input 32 + type="text" 33 + value={spacedustUrl()} 34 + onChange={(e) => void props.handleUpdateSetting("spacedustUrl", e.currentTarget.value)} 35 + class="flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-on-surface outline-none transition focus:border-primary/50" /> 36 + </div> 37 + </div> 38 + <ToggleRow 39 + label="Use Spacedust for real-time" 40 + description="WebSocket push notifications" 41 + checked={spacedustEnabled()} 42 + onChange={() => void props.handleUpdateSetting("spacedustEnabled", !spacedustEnabled())} /> 43 + <ToggleRow 44 + label="Instant mode" 45 + description="Bypass 21s debounce buffer" 46 + checked={spacedustInstant()} 47 + onChange={() => void props.handleUpdateSetting("spacedustInstant", !spacedustInstant())} /> 48 + </div> 49 + </SettingsCard> 50 + ); 51 + }
+27
src/components/settings/SettingsTheme.tsx
··· 1 + import type { AppSettings, Theme } from "$/lib/types"; 2 + import { SegmentedControl } from "../shared/SegmentedControl"; 3 + import { SettingsCard } from "./SettingsCard"; 4 + 5 + const THEME_OPTIONS: { value: Theme; label: string }[] = [{ value: "light", label: "Light" }, { 6 + value: "dark", 7 + label: "Dark", 8 + }, { value: "auto", label: "Auto" }]; 9 + 10 + export function AppearanceControl( 11 + props: { currentTheme: Theme; handleUpdateSetting: (key: keyof AppSettings, value: string) => void }, 12 + ) { 13 + return ( 14 + <SettingsCard icon="theme" title="Appearance"> 15 + <div class="flex items-center justify-between"> 16 + <div> 17 + <p class="text-sm font-medium text-on-surface">Theme</p> 18 + <p class="text-xs text-on-surface-variant">Choose your preferred color scheme</p> 19 + </div> 20 + <SegmentedControl 21 + options={THEME_OPTIONS} 22 + value={props.currentTheme} 23 + onChange={(v) => void props.handleUpdateSetting("theme", v)} /> 24 + </div> 25 + </SettingsCard> 26 + ); 27 + }
+33
src/components/settings/SettingsTimeline.tsx
··· 1 + import type { AppSettings, RefreshInterval } from "$/lib/types"; 2 + import { SegmentedControl } from "../shared/SegmentedControl"; 3 + import { SettingsCard } from "./SettingsCard"; 4 + 5 + const REFRESH_OPTIONS: { value: RefreshInterval; label: string }[] = [ 6 + { value: 30, label: "30s" }, 7 + { value: 60, label: "1m" }, 8 + { value: 120, label: "2m" }, 9 + { value: 300, label: "5m" }, 10 + { value: 0, label: "Manual" }, 11 + ]; 12 + 13 + export function TimelineControl( 14 + props: { 15 + currentRefresh: RefreshInterval; 16 + handleUpdateSetting: (key: keyof AppSettings, value: string | number) => void; 17 + }, 18 + ) { 19 + return ( 20 + <SettingsCard icon="timeline" title="Timeline"> 21 + <div class="flex items-center justify-between gap-4"> 22 + <div> 23 + <p class="text-sm font-medium text-on-surface">Auto-refresh interval</p> 24 + <p class="text-xs text-on-surface-variant">How often to check for new posts</p> 25 + </div> 26 + <SegmentedControl 27 + options={REFRESH_OPTIONS} 28 + value={props.currentRefresh} 29 + onChange={(v) => void props.handleUpdateSetting("timelineRefreshSecs", v)} /> 30 + </div> 31 + </SettingsCard> 32 + ); 33 + }
+28
src/components/settings/SettingsToggleRow.tsx
··· 1 + import { Motion } from "solid-motionone"; 2 + 3 + export function ToggleRow( 4 + props: { checked: boolean; description: string; disabled?: boolean; label: string; onChange: () => void }, 5 + ) { 6 + return ( 7 + <div class="flex items-center justify-between"> 8 + <div> 9 + <p class="text-sm font-medium text-on-surface">{props.label}</p> 10 + <p class="text-xs text-on-surface-variant">{props.description}</p> 11 + </div> 12 + <button 13 + type="button" 14 + role="switch" 15 + aria-label={props.label} 16 + aria-checked={props.checked} 17 + disabled={props.disabled} 18 + onClick={() => props.onChange()} 19 + class="relative inline-flex h-6 w-10 items-center rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50" 20 + classList={{ "bg-primary": props.checked, "bg-white/20": !props.checked }}> 21 + <Motion.span 22 + class="inline-block h-4 w-4 rounded-full bg-on-primary-fixed shadow-lg" 23 + animate={{ x: props.checked ? 20 : 2 }} 24 + transition={{ duration: 0.15, easing: [0.25, 0.1, 0.25, 1] }} /> 25 + </button> 26 + </div> 27 + ); 28 + }
+53 -1
src/components/shared/Icon.tsx
··· 2 2 import type { ExplorerTargetKind } from "$/lib/api/types/explorer"; 3 3 import { type JSX, Match, splitProps, Switch } from "solid-js"; 4 4 5 + export type SettingsIconKind = 6 + | "computer" 7 + | "info" 8 + | "timeline" 9 + | "db" 10 + | "notifications" 11 + | "user" 12 + | "services" 13 + | "theme"; 14 + 5 15 export type IconKind = 6 16 | "explorer" 7 17 | "ext-link" ··· 32 42 | "repost" 33 43 | "reply" 34 44 | "follow" 35 - | "download"; 45 + | "download" 46 + | "info" 47 + | "computer" 48 + | "timeline" 49 + | "db" 50 + | "notifications" 51 + | "user" 52 + | "services" 53 + | "theme"; 36 54 37 55 type IconProps = JSX.HTMLAttributes<HTMLSpanElement> & { 38 56 class?: string; ··· 136 154 </Match> 137 155 <Match when={local.kind === "settings"}> 138 156 <i class="i-ri-settings-3-line" /> 157 + </Match> 158 + </Switch> 159 + </span> 160 + ); 161 + } 162 + 163 + export function SettingsIcon(props: IconProps & { kind: SettingsIconKind }) { 164 + const [local, rest] = splitProps(props, ["class", "iconClass", "kind", "name"]); 165 + return ( 166 + <span {...rest} class="flex items-center justify-center" classList={{ [local.class ?? ""]: !!local.class }}> 167 + <Switch> 168 + <Match when={local.kind === "info"}> 169 + <i class="i-ri-information-line" /> 170 + </Match> 171 + <Match when={local.kind === "computer"}> 172 + <i class="i-ri-computer-line" /> 173 + </Match> 174 + <Match when={local.kind === "timeline"}> 175 + <i class="i-ri-timeline-view" /> 176 + </Match> 177 + <Match when={local.kind === "db"}> 178 + <i class="i-ri-database-2-line" /> 179 + </Match> 180 + <Match when={local.kind === "notifications"}> 181 + <i class="i-ri-notification-3-line" /> 182 + </Match> 183 + <Match when={local.kind === "user"}> 184 + <i class="i-ri-user-lin" /> 185 + </Match> 186 + <Match when={local.kind === "services"}> 187 + <i class="i-ri-global-line" /> 188 + </Match> 189 + <Match when={local.kind === "theme"}> 190 + <i class="i-ri-paint-line" /> 139 191 </Match> 140 192 </Switch> 141 193 </span>
+24
src/components/shared/SegmentedControl.tsx
··· 1 + import { For } from "solid-js"; 2 + 3 + export function SegmentedControl<T extends string | number>( 4 + props: { options: { value: T; label: string }[]; value: T; onChange: (value: T) => void }, 5 + ) { 6 + return ( 7 + <div class="flex rounded-xl bg-black/40 p-1"> 8 + <For each={props.options}> 9 + {(option) => ( 10 + <button 11 + type="button" 12 + onClick={() => props.onChange(option.value)} 13 + class="flex-1 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors" 14 + classList={{ 15 + "bg-primary/20 text-primary": props.value === option.value, 16 + "text-on-surface-variant hover:text-on-surface": props.value !== option.value, 17 + }}> 18 + {option.label} 19 + </button> 20 + )} 21 + </For> 22 + </div> 23 + ); 24 + }
+180
src/contexts/app-preferences.tsx
··· 1 + import { 2 + getEmbeddingsConfig, 3 + prepareEmbeddingsModel as prepareEmbeddingsModelRequest, 4 + setEmbeddingsEnabled as setEmbeddingsEnabledRequest, 5 + } from "$/lib/api/search"; 6 + import type { EmbeddingsConfig } from "$/lib/api/search"; 7 + import { getSettings, updateSetting as updateSettingRequest } from "$/lib/api/settings"; 8 + import type { AppSettings } from "$/lib/types"; 9 + import * as logger from "@tauri-apps/plugin-log"; 10 + import { createContext, onMount, type ParentProps, splitProps, untrack, useContext } from "solid-js"; 11 + import { createStore } from "solid-js/store"; 12 + 13 + type AppPreferencesState = { 14 + embeddingsConfig: EmbeddingsConfig | null; 15 + embeddingsLoading: boolean; 16 + settings: AppSettings | null; 17 + settingsLoading: boolean; 18 + }; 19 + 20 + export type AppPreferencesContextValue = { 21 + readonly embeddingsConfig: EmbeddingsConfig | null; 22 + readonly embeddingsEnabled: boolean; 23 + readonly embeddingsLoading: boolean; 24 + readonly settings: AppSettings | null; 25 + readonly settingsLoading: boolean; 26 + loadEmbeddingsConfig: () => Promise<void>; 27 + loadSettings: () => Promise<void>; 28 + prepareEmbeddingsModel: () => Promise<void>; 29 + refresh: () => Promise<void>; 30 + setEmbeddingsEnabled: (enabled: boolean) => Promise<void>; 31 + updateSetting: (key: keyof AppSettings, value: string | boolean | number) => Promise<void>; 32 + }; 33 + 34 + const AppPreferencesContext = createContext<AppPreferencesContextValue>(); 35 + 36 + function createInitialAppPreferencesState(): AppPreferencesState { 37 + return { embeddingsConfig: null, embeddingsLoading: true, settings: null, settingsLoading: true }; 38 + } 39 + 40 + function createAppPreferencesValue(): AppPreferencesContextValue { 41 + const [preferences, setPreferences] = createStore<AppPreferencesState>(createInitialAppPreferencesState()); 42 + 43 + async function loadSettings() { 44 + setPreferences("settingsLoading", true); 45 + 46 + try { 47 + setPreferences("settings", await getSettings()); 48 + } catch (error) { 49 + logger.error("failed to load settings", { keyValues: { error: String(error) } }); 50 + } finally { 51 + setPreferences("settingsLoading", false); 52 + } 53 + } 54 + 55 + async function updateSetting(key: keyof AppSettings, value: string | boolean | number) { 56 + const serialized = typeof value === "boolean" ? (value ? "1" : "0") : String(value); 57 + 58 + try { 59 + await updateSettingRequest(key, serialized); 60 + 61 + setPreferences("settings", (current) => { 62 + if (!current) { 63 + return current; 64 + } 65 + 66 + return { ...current, [key]: value }; 67 + }); 68 + } catch (error) { 69 + logger.error("failed to update setting", { keyValues: { key, error: String(error) } }); 70 + } 71 + } 72 + 73 + async function loadEmbeddingsConfig() { 74 + setPreferences("embeddingsLoading", true); 75 + 76 + try { 77 + const nextConfig = await getEmbeddingsConfig(); 78 + setPreferences("embeddingsConfig", nextConfig); 79 + setPreferences("settings", (current) => { 80 + if (!current) { 81 + return current; 82 + } 83 + 84 + return { ...current, embeddingsEnabled: nextConfig.enabled }; 85 + }); 86 + } catch (error) { 87 + logger.error("failed to load embeddings config", { keyValues: { error: String(error) } }); 88 + } finally { 89 + setPreferences("embeddingsLoading", false); 90 + } 91 + } 92 + 93 + async function prepareEmbeddingsModel() { 94 + try { 95 + const nextConfig = await prepareEmbeddingsModelRequest(); 96 + setPreferences("embeddingsConfig", nextConfig); 97 + setPreferences("settings", (current) => { 98 + if (!current) { 99 + return current; 100 + } 101 + 102 + return { ...current, embeddingsEnabled: nextConfig.enabled }; 103 + }); 104 + } catch (error) { 105 + logger.error("failed to prepare embeddings model", { keyValues: { error: String(error) } }); 106 + } 107 + } 108 + 109 + async function setEmbeddingsEnabled(enabled: boolean) { 110 + try { 111 + await setEmbeddingsEnabledRequest(enabled); 112 + setPreferences("settings", (current) => { 113 + if (!current) { 114 + return current; 115 + } 116 + 117 + return { ...current, embeddingsEnabled: enabled }; 118 + }); 119 + await loadEmbeddingsConfig(); 120 + } catch (error) { 121 + logger.error("failed to set embeddings enabled", { 122 + keyValues: { enabled: String(enabled), error: String(error) }, 123 + }); 124 + } 125 + } 126 + 127 + async function refresh() { 128 + await Promise.all([loadSettings(), loadEmbeddingsConfig()]); 129 + } 130 + 131 + onMount(() => { 132 + void refresh(); 133 + }); 134 + 135 + return { 136 + get embeddingsConfig() { 137 + return preferences.embeddingsConfig; 138 + }, 139 + get embeddingsEnabled() { 140 + return preferences.embeddingsConfig?.enabled ?? preferences.settings?.embeddingsEnabled ?? true; 141 + }, 142 + get embeddingsLoading() { 143 + return preferences.embeddingsLoading; 144 + }, 145 + get settings() { 146 + return preferences.settings; 147 + }, 148 + get settingsLoading() { 149 + return preferences.settingsLoading; 150 + }, 151 + loadEmbeddingsConfig, 152 + loadSettings, 153 + prepareEmbeddingsModel, 154 + refresh, 155 + setEmbeddingsEnabled, 156 + updateSetting, 157 + }; 158 + } 159 + 160 + export function AppPreferencesProvider(props: ParentProps) { 161 + const value = createAppPreferencesValue(); 162 + 163 + return <AppPreferencesContext.Provider value={value}>{props.children}</AppPreferencesContext.Provider>; 164 + } 165 + 166 + export function AppPreferencesContextProvider(props: ParentProps<{ value: AppPreferencesContextValue }>) { 167 + const [local] = splitProps(props, ["children", "value"]); 168 + const value = untrack(() => local.value); 169 + 170 + return <AppPreferencesContext.Provider value={value}>{local.children}</AppPreferencesContext.Provider>; 171 + } 172 + 173 + export function useAppPreferences() { 174 + const context = useContext(AppPreferencesContext); 175 + if (!context) { 176 + throw new Error("useAppPreferences must be used within an AppPreferencesProvider"); 177 + } 178 + 179 + return context; 180 + }
+37
src/lib/api/settings.ts
··· 1 + import type { AppSettings, CacheClearScope, CacheSize, ExportFormat, LogEntry, LogLevelFilter } from "$/lib/types"; 2 + import { invoke } from "@tauri-apps/api/core"; 3 + import * as logger from "@tauri-apps/plugin-log"; 4 + import { normalizeError } from "../utils/text"; 5 + export function getSettings() { 6 + return invoke<AppSettings>("get_settings"); 7 + } 8 + 9 + export function updateSetting(key: string, value: string) { 10 + return invoke("update_setting", { key, value }); 11 + } 12 + 13 + export function getCacheSize() { 14 + return invoke<CacheSize>("get_cache_size"); 15 + } 16 + 17 + export function clearCache(scope: CacheClearScope) { 18 + return invoke("clear_cache", { scope }); 19 + } 20 + 21 + export async function exportData(format: ExportFormat, path?: string) { 22 + try { 23 + const now = Date.now(); 24 + await invoke("export_data", { format, path: path ?? `lazurite_${now}_export.${format}` }); 25 + } catch (err) { 26 + logger.error("failed to export data", { keyValues: { error: normalizeError(err) } }); 27 + } 28 + } 29 + 30 + export function resetApp() { 31 + return invoke("reset_app"); 32 + } 33 + 34 + export function getLogEntries(limit: number, level?: LogLevelFilter) { 35 + const filterLevel = level === "all" ? null : level; 36 + return invoke<LogEntry[]>("get_log_entries", { limit, level: filterLevel }); 37 + }
+28
src/lib/types.ts
··· 200 200 export type EmbedInput = { type: "record"; record: StrongRefInput }; 201 201 202 202 export type CreateRecordResult = { cid: string; uri: string }; 203 + 204 + export type AppSettings = { 205 + theme: string; 206 + timelineRefreshSecs: number; 207 + notificationsDesktop: boolean; 208 + notificationsBadge: boolean; 209 + notificationsSound: boolean; 210 + embeddingsEnabled: boolean; 211 + constellationUrl: string; 212 + spacedustUrl: string; 213 + spacedustInstant: boolean; 214 + spacedustEnabled: boolean; 215 + globalShortcut: string; 216 + }; 217 + 218 + export type CacheSize = { feedsBytes: number; embeddingsBytes: number; ftsBytes: number; totalBytes: number }; 219 + 220 + export type LogEntry = { timestamp: string | null; level: string; target: string | null; message: string }; 221 + 222 + export type CacheClearScope = "all" | "feeds" | "embeddings" | "fts"; 223 + 224 + export type ExportFormat = "json" | "csv"; 225 + 226 + export type LogLevelFilter = "all" | "info" | "warn" | "error"; 227 + 228 + export type RefreshInterval = 30 | 60 | 120 | 300 | 0; 229 + 230 + export type Theme = "light" | "dark" | "auto";
+8
src/lib/utils/text.ts
··· 41 41 42 42 return `${Math.round(value)}%`; 43 43 } 44 + 45 + export function formatBytes(bytes: number): string { 46 + if (bytes === 0) return "0 B"; 47 + const k = 1024; 48 + const sizes = ["B", "KB", "MB", "GB"]; 49 + const i = Math.floor(Math.log(bytes) / Math.log(k)); 50 + return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; 51 + }
+11 -9
src/router.tsx
··· 1 1 import { useAppSession } from "$/contexts/app-session"; 2 2 import { useAppShellUi } from "$/contexts/app-shell-ui"; 3 - import { 4 - HashRouter, 5 - Navigate, 6 - Route, 7 - type RouteSectionProps, 8 - useLocation, 9 - useNavigate, 10 - useParams, 11 - } from "@solidjs/router"; 3 + import { HashRouter, Navigate, Route, useLocation, useNavigate, useParams } from "@solidjs/router"; 4 + import type { RouteSectionProps } from "@solidjs/router"; 12 5 import { type Component, createEffect, type JSX, type ParentProps, Show } from "solid-js"; 13 6 import { Dynamic } from "solid-js/web"; 14 7 import { ExplorerPanel } from "./components/explorer/ExplorerPanel"; 15 8 import { SearchPanel } from "./components/search/SearchPanel"; 9 + import { SettingsPanel } from "./components/settings/SettingsPanel"; 16 10 import { buildThreadRoute, decodeThreadRouteUri, TIMELINE_ROUTE } from "./lib/feeds"; 17 11 18 12 type TTimelineRouteProps = { context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null } }; 13 + 19 14 type AppShellProps = ParentProps<{ fullWidth?: boolean }>; 20 15 21 16 type AppRouterProps = { ··· 91 86 </ProtectedRouteView> 92 87 ); 93 88 89 + const SettingsRoute = () => ( 90 + <ProtectedRouteView> 91 + <SettingsPanel /> 92 + </ProtectedRouteView> 93 + ); 94 + 94 95 const NotFoundRoute = () => ( 95 96 <Show when={session.bootstrapping} fallback={<Navigate href={session.hasSession ? TIMELINE_ROUTE : "/auth"} />}> 96 97 <RouteLoadingState /> ··· 107 108 <Route path="/search" component={SearchRoute} /> 108 109 <Route path="/notifications" component={NotificationsRoute} /> 109 110 <Route path="/explorer" component={ExplorerRoute} /> 111 + <Route path="/settings" component={SettingsRoute} /> 110 112 <Route path="*404" component={NotFoundRoute} /> 111 113 </HashRouter> 112 114 );
+56 -5
src/test/providers.tsx
··· 1 + import { AppPreferencesContextProvider, type AppPreferencesContextValue } from "$/contexts/app-preferences"; 1 2 import { AppSessionContextProvider, type AppSessionContextValue } from "$/contexts/app-session"; 2 3 import { AppShellUiContextProvider, type AppShellUiContextValue } from "$/contexts/app-shell-ui"; 3 4 import type { AccountSummary, ActiveSession } from "$/lib/types"; ··· 14 15 }; 15 16 16 17 function noop() {} 18 + 19 + const DEFAULT_SETTINGS = { 20 + theme: "auto", 21 + timelineRefreshSecs: 60, 22 + notificationsDesktop: true, 23 + notificationsBadge: true, 24 + notificationsSound: false, 25 + embeddingsEnabled: true, 26 + constellationUrl: "https://constellation.microcosm.blue", 27 + spacedustUrl: "https://spacedust.microcosm.blue", 28 + spacedustInstant: false, 29 + spacedustEnabled: false, 30 + globalShortcut: "Ctrl+Shift+N", 31 + }; 32 + 33 + const DEFAULT_EMBEDDINGS_CONFIG = { 34 + enabled: true, 35 + modelName: "nomic-embed-text-v1.5", 36 + dimensions: 768, 37 + downloaded: true, 38 + downloadActive: false, 39 + }; 17 40 18 41 export function createAppSessionTestValue(overrides: Partial<AppSessionContextValue> = {}): AppSessionContextValue { 19 42 const accounts = overrides.accounts ?? [DEFAULT_ACCOUNT]; ··· 68 91 }; 69 92 } 70 93 94 + export function createAppPreferencesTestValue( 95 + overrides: Partial<AppPreferencesContextValue> = {}, 96 + ): AppPreferencesContextValue { 97 + return { 98 + embeddingsConfig: overrides.embeddingsConfig ?? DEFAULT_EMBEDDINGS_CONFIG, 99 + embeddingsEnabled: overrides.embeddingsEnabled ?? overrides.embeddingsConfig?.enabled 100 + ?? DEFAULT_EMBEDDINGS_CONFIG.enabled, 101 + embeddingsLoading: overrides.embeddingsLoading ?? false, 102 + settings: overrides.settings ?? DEFAULT_SETTINGS, 103 + settingsLoading: overrides.settingsLoading ?? false, 104 + loadEmbeddingsConfig: overrides.loadEmbeddingsConfig ?? (async () => {}), 105 + loadSettings: overrides.loadSettings ?? (async () => {}), 106 + prepareEmbeddingsModel: overrides.prepareEmbeddingsModel ?? (async () => {}), 107 + refresh: overrides.refresh ?? (async () => {}), 108 + setEmbeddingsEnabled: overrides.setEmbeddingsEnabled ?? (async () => {}), 109 + updateSetting: overrides.updateSetting ?? (async () => {}), 110 + }; 111 + } 112 + 71 113 export function AppTestProviders( 72 - props: ParentProps<{ session?: Partial<AppSessionContextValue>; shell?: Partial<AppShellUiContextValue> }>, 114 + props: ParentProps< 115 + { 116 + preferences?: Partial<AppPreferencesContextValue>; 117 + session?: Partial<AppSessionContextValue>; 118 + shell?: Partial<AppShellUiContextValue>; 119 + } 120 + >, 73 121 ) { 74 - const [local] = splitProps(props, ["children", "session", "shell"]); 122 + const [local] = splitProps(props, ["children", "preferences", "session", "shell"]); 123 + const preferencesValue = createAppPreferencesTestValue(untrack(() => local.preferences)); 75 124 const sessionValue = createAppSessionTestValue(untrack(() => local.session)); 76 125 const shellValue = createAppShellUiTestValue(untrack(() => local.shell)); 77 126 78 127 return ( 79 - <AppSessionContextProvider value={sessionValue}> 80 - <AppShellUiContextProvider value={shellValue}>{local.children}</AppShellUiContextProvider> 81 - </AppSessionContextProvider> 128 + <AppPreferencesContextProvider value={preferencesValue}> 129 + <AppSessionContextProvider value={sessionValue}> 130 + <AppShellUiContextProvider value={shellValue}>{local.children}</AppShellUiContextProvider> 131 + </AppSessionContextProvider> 132 + </AppPreferencesContextProvider> 82 133 ); 83 134 }