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: search UI

+1246 -98
+2 -2
docs/tasks/06-search.md
··· 56 56 57 57 #### Search UI 58 58 59 - - [ ] search bar (`/` or `CTRL/CMD + F` to focus) with mode selector (network / keyword / semantic / hybrid), `Motion` sliding indicator underline 60 - - [ ] search results with staggered `Motion` fade-in, highlighted keyword matches 59 + - [x] search bar (`/` or `CTRL/CMD + F` to focus) with mode selector (network / keyword / semantic / hybrid), `Motion` sliding indicator underline 60 + - [x] search results with staggered `Motion` fade-in, highlighted keyword matches 61 61 62 62 #### Embeddings 63 63
+1 -3
src/components/explorer/ExplorerUrlBar.tsx
··· 34 34 return ( 35 35 <form onSubmit={handleSubmit} class="flex-1 relative"> 36 36 <div class="flex items-center gap-3 px-4 py-2 rounded-xl bg-black/40 shadow-[inset_0_0_0_1px_rgba(125,175,255,0.12)]"> 37 - <span class="flex items-center text-primary/80"> 38 - <i class="i-ri-compass-discover-line" /> 39 - </span> 37 + <Icon kind="explore" class="text-primary/80" /> 40 38 <input 41 39 data-explorer-input 42 40 type="text"
+50 -62
src/components/notifications/NotificationItem.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 1 2 import { formatRelativeTime, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 2 3 import type { NotificationReason, NotificationView } from "$/lib/types"; 3 - import { createMemo, Show } from "solid-js"; 4 - import { Icon } from "../shared/Icon"; 5 - 6 - type ReasonStyle = { color: string; iconClass: string }; 7 - 8 - export function reasonStyle(reason: NotificationReason): ReasonStyle { 9 - switch (reason) { 10 - case "like": { 11 - return { color: "text-[#ff6b6b]", iconClass: "i-ri-heart-3-fill" }; 12 - } 13 - case "repost": { 14 - return { color: "text-[#4cd964]", iconClass: "i-ri-repeat-2-line" }; 15 - } 16 - case "mention": 17 - case "reply": { 18 - return { color: "text-primary", iconClass: "i-ri-chat-3-line" }; 19 - } 20 - case "quote": { 21 - return { color: "text-primary", iconClass: "i-ri-chat-quote-line" }; 22 - } 23 - case "follow": { 24 - return { color: "text-primary", iconClass: "i-ri-user-add-line" }; 25 - } 26 - default: { 27 - return { color: "text-on-surface-variant", iconClass: "i-ri-notification-3-line" }; 28 - } 29 - } 30 - } 4 + import { createMemo, Match, Show, Switch } from "solid-js"; 31 5 32 - export function reasonText(reason: NotificationReason): string { 33 - switch (reason) { 34 - case "like": { 35 - return "liked your post"; 36 - } 37 - case "repost": { 38 - return "reposted your post"; 39 - } 40 - case "mention": { 41 - return "mentioned you"; 42 - } 43 - case "reply": { 44 - return "replied to you"; 45 - } 46 - case "quote": { 47 - return "quoted your post"; 48 - } 49 - case "follow": { 50 - return "followed you"; 51 - } 52 - default: { 53 - return "interacted with your post"; 54 - } 55 - } 6 + function ReasonIcon(props: { reason: NotificationReason }) { 7 + return ( 8 + <div class="flex w-8 shrink-0 justify-center pt-0.5"> 9 + <Switch fallback={<Icon kind="notifications" class="text-on-surface-variant" aria-hidden="true" />}> 10 + <Match when={props.reason === "like"}> 11 + <Icon kind="heart" class="text-[#ff6b6b]" aria-hidden="true" /> 12 + </Match> 13 + <Match when={props.reason === "repost"}> 14 + <Icon kind="repost" class="text-[#4cd964]" aria-hidden="true" /> 15 + </Match> 16 + <Match when={props.reason === "mention" || props.reason === "reply"}> 17 + <Icon kind="reply" class="text-primary" aria-hidden="true" /> 18 + </Match> 19 + <Match when={props.reason === "quote"}> 20 + <Icon kind="quote" class="text-primary" aria-hidden="true" /> 21 + </Match> 22 + <Match when={props.reason === "follow"}> 23 + <Icon kind="follow" class="text-primary" aria-hidden="true" /> 24 + </Match> 25 + </Switch> 26 + </div> 27 + ); 56 28 } 57 29 58 30 function AuthorAvatar(props: { avatar?: string | null; label: string }) { ··· 68 40 type NotificationItemProps = { notification: NotificationView }; 69 41 70 42 export function NotificationItem(props: NotificationItemProps) { 71 - const style = createMemo(() => reasonStyle(props.notification.reason)); 72 43 const name = createMemo(() => getDisplayName(props.notification.author)); 73 - const description = createMemo(() => reasonText(props.notification.reason)); 44 + const description = createMemo(() => { 45 + switch (props.notification.reason) { 46 + case "like": { 47 + return "liked your post"; 48 + } 49 + case "repost": { 50 + return "reposted your post"; 51 + } 52 + case "mention": { 53 + return "mentioned you"; 54 + } 55 + case "reply": { 56 + return "replied to you"; 57 + } 58 + case "quote": { 59 + return "quoted your post"; 60 + } 61 + case "follow": { 62 + return "followed you"; 63 + } 64 + default: { 65 + return "interacted with your post"; 66 + } 67 + } 68 + }); 74 69 const time = createMemo(() => formatRelativeTime(props.notification.indexedAt)); 75 70 const avatarLabel = createMemo(() => getAvatarLabel(props.notification.author)); 76 71 const postText = createMemo<string | null>(() => { ··· 85 80 class="flex items-start gap-4 rounded-2xl px-4 py-4 transition-colors duration-150 hover:bg-surface-container-high" 86 81 classList={{ "opacity-60": props.notification.isRead }} 87 82 aria-label={`${name()} ${description()}`}> 88 - <div class="flex w-8 shrink-0 justify-center pt-0.5"> 89 - <Icon 90 - iconClass={style().iconClass} 91 - class={`text-base ${style().color}`} 92 - aria-hidden="true" 93 - name={`${name()} ${description()}`} /> 94 - </div> 95 - 83 + <ReasonIcon reason={props.notification.reason} /> 96 84 <AuthorAvatar avatar={props.notification.author.avatar} label={avatarLabel()} /> 97 85 98 86 <div class="min-w-0 flex-1">
+2 -3
src/components/notifications/NotificationsPanel.tsx
··· 6 6 import * as logger from "@tauri-apps/plugin-log"; 7 7 import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 8 8 import { Motion, Presence } from "solid-motionone"; 9 + import { Icon } from "../shared/Icon"; 9 10 import { NotificationItem } from "./NotificationItem"; 10 11 11 12 type Tab = "mentions" | "activity"; ··· 102 103 class="inline-flex h-10 items-center gap-2 rounded-full border-0 bg-surface-container-high px-4 text-sm font-medium text-on-surface-variant transition duration-150 hover:-translate-y-px hover:text-on-surface" 103 104 onClick={() => props.onMarkSeen()} 104 105 title="Mark all as read"> 105 - <span class="flex items-center" aria-hidden="true"> 106 - <i class="i-ri-check-double-line" /> 107 - </span> 106 + <Icon kind="complete" aria-hidden="true" /> 108 107 Mark all read 109 108 </button> 110 109 </div>
+88
src/components/search/SearchEmptyState.tsx
··· 1 + import { Match, Show, Switch } from "solid-js"; 2 + import { Icon } from "../shared/Icon"; 3 + 4 + type SearchEmptyStateProps = { reason: "initial" | "no-results" | "no-sync" }; 5 + 6 + export function SearchEmptyState(props: SearchEmptyStateProps) { 7 + return ( 8 + <div class="text-center"> 9 + <EmptyStateIcon reason={props.reason} /> 10 + <EmptyStateContent reason={props.reason} /> 11 + </div> 12 + ); 13 + } 14 + 15 + function EmptyStateIcon(props: { reason: string }) { 16 + return ( 17 + <div class="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5"> 18 + <Show 19 + when={props.reason === "no-sync"} 20 + fallback={<Icon kind="search" class="text-3xl text-on-surface-variant" />}> 21 + <Icon kind="db" class="text-3xl text-on-surface-variant" /> 22 + </Show> 23 + </div> 24 + ); 25 + } 26 + 27 + function EmptyStateContent(props: { reason: string }) { 28 + return ( 29 + <Switch> 30 + <Match when={props.reason === "initial"}> 31 + <InitialContent /> 32 + </Match> 33 + 34 + <Match when={props.reason === "no-results"}> 35 + <NoResultsContent /> 36 + </Match> 37 + 38 + <Match when={props.reason === "no-sync"}> 39 + <NoSyncContent /> 40 + </Match> 41 + </Switch> 42 + ); 43 + } 44 + 45 + function InitialContent() { 46 + return ( 47 + <> 48 + <h3 class="mb-1 text-base font-medium text-on-surface">Search your saved posts</h3> 49 + <p class="m-0 text-sm text-on-surface-variant"> 50 + Type a query above to search through your liked and bookmarked posts. 51 + </p> 52 + <KeyboardShortcuts /> 53 + </> 54 + ); 55 + } 56 + 57 + function KeyboardShortcuts() { 58 + return ( 59 + <div class="mt-4 space-y-1 text-xs text-on-surface-variant/60"> 60 + <p> 61 + <kbd class="rounded bg-white/10 px-1.5 py-0.5">/</kbd> Focus search from anywhere 62 + </p> 63 + <p> 64 + <kbd class="rounded bg-white/10 px-1.5 py-0.5">Tab</kbd> Cycle search modes 65 + </p> 66 + </div> 67 + ); 68 + } 69 + 70 + function NoResultsContent() { 71 + return ( 72 + <> 73 + <h3 class="mb-1 text-base font-medium text-on-surface">No results found</h3> 74 + <p class="m-0 text-sm text-on-surface-variant"> 75 + Try adjusting your search terms or switch to a different search mode. 76 + </p> 77 + </> 78 + ); 79 + } 80 + 81 + function NoSyncContent() { 82 + return ( 83 + <> 84 + <h3 class="mb-1 text-base font-medium text-on-surface">No posts synced yet</h3> 85 + <p class="m-0 text-sm text-on-surface-variant">Sync your liked and bookmarked posts to enable local search.</p> 86 + </> 87 + ); 88 + }
+182
src/components/search/SearchPanel.test.tsx
··· 1 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { SearchPanel } from "./SearchPanel"; 4 + 5 + const searchPostsMock = vi.hoisted(() => vi.fn()); 6 + const searchPostsNetworkMock = vi.hoisted(() => vi.fn()); 7 + const getSyncStatusMock = vi.hoisted(() => vi.fn()); 8 + const syncPostsMock = vi.hoisted(() => vi.fn()); 9 + 10 + vi.mock( 11 + "$/lib/api/search", 12 + () => ({ 13 + searchPosts: searchPostsMock, 14 + searchPostsNetwork: searchPostsNetworkMock, 15 + getSyncStatus: getSyncStatusMock, 16 + syncPosts: syncPostsMock, 17 + }), 18 + ); 19 + 20 + vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); 21 + 22 + describe("SearchPanel", () => { 23 + beforeEach(() => { 24 + vi.useFakeTimers(); 25 + searchPostsMock.mockReset(); 26 + searchPostsNetworkMock.mockReset(); 27 + getSyncStatusMock.mockReset(); 28 + syncPostsMock.mockReset(); 29 + 30 + getSyncStatusMock.mockResolvedValue([]); 31 + syncPostsMock.mockResolvedValue({ 32 + did: "did:plc:test", 33 + source: "like", 34 + post_count: 100, 35 + last_synced_at: "2026-03-29T12:00:00.000Z", 36 + }); 37 + }); 38 + 39 + it("renders the search panel with initial state", async () => { 40 + render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 41 + 42 + expect(await screen.findByPlaceholderText("Search posts...")).toBeInTheDocument(); 43 + expect(screen.getByText("Network")).toBeInTheDocument(); 44 + expect(screen.getByText("Keyword")).toBeInTheDocument(); 45 + expect(screen.getByText("Semantic")).toBeInTheDocument(); 46 + expect(screen.getByText("Hybrid")).toBeInTheDocument(); 47 + }); 48 + 49 + it("switches search modes when clicking mode buttons", async () => { 50 + render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 51 + 52 + const keywordButton = screen.getByRole("button", { name: /keyword/i }); 53 + fireEvent.click(keywordButton); 54 + 55 + await waitFor(() => { 56 + expect(keywordButton).toHaveAttribute("aria-pressed", "true"); 57 + }); 58 + }); 59 + 60 + it("performs network search when typing", async () => { 61 + searchPostsNetworkMock.mockResolvedValue({ 62 + posts: [{ 63 + uri: "at://test", 64 + cid: "cid-1", 65 + author: { did: "did:plc:test", handle: "test.bsky.social" }, 66 + indexedAt: "2026-03-29T12:00:00.000Z", 67 + record: { text: "Test post content" }, 68 + }], 69 + }); 70 + 71 + render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 72 + 73 + const input = await screen.findByPlaceholderText("Search posts..."); 74 + fireEvent.input(input, { target: { value: "test query" } }); 75 + 76 + vi.advanceTimersByTime(350); 77 + 78 + await waitFor(() => { 79 + expect(searchPostsNetworkMock).toHaveBeenCalledWith("test query", "top", 25); 80 + }); 81 + 82 + expect(await screen.findByText("Test post content")).toBeInTheDocument(); 83 + }); 84 + 85 + it("performs local search in keyword mode", async () => { 86 + searchPostsMock.mockResolvedValue([{ 87 + uri: "at://test", 88 + cid: "cid-1", 89 + author_did: "did:plc:test", 90 + author_handle: "test.bsky.social", 91 + text: "Local test post", 92 + created_at: "2026-03-29T12:00:00.000Z", 93 + indexed_at: "2026-03-29T12:00:00.000Z", 94 + source: "like" as const, 95 + }]); 96 + 97 + render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 98 + 99 + const keywordButton = screen.getByRole("button", { name: /keyword/i }); 100 + fireEvent.click(keywordButton); 101 + 102 + const input = await screen.findByPlaceholderText("Search posts..."); 103 + fireEvent.input(input, { target: { value: "test query" } }); 104 + 105 + vi.advanceTimersByTime(350); 106 + 107 + await waitFor(() => { 108 + expect(searchPostsMock).toHaveBeenCalledWith("test query", "keyword", 50); 109 + }); 110 + 111 + expect(await screen.findByText("Local test post")).toBeInTheDocument(); 112 + }); 113 + 114 + it("cycles through modes with Tab key", async () => { 115 + render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 116 + 117 + const input = await screen.findByPlaceholderText("Search posts..."); 118 + input.focus(); 119 + fireEvent.keyDown(input, { key: "Tab" }); 120 + 121 + expect(screen.getByRole("button", { name: /keyword/i })).toHaveAttribute("aria-pressed", "true"); 122 + }, 5000); 123 + 124 + it("clears search with Escape key", async () => { 125 + searchPostsNetworkMock.mockResolvedValue({ 126 + posts: [{ 127 + uri: "at://test", 128 + cid: "cid-1", 129 + author: { did: "did:plc:test", handle: "test.bsky.social" }, 130 + indexedAt: "2026-03-29T12:00:00.000Z", 131 + record: { text: "Test content" }, 132 + }], 133 + }); 134 + 135 + render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 136 + 137 + const input = await screen.findByPlaceholderText("Search posts..."); 138 + fireEvent.input(input, { target: { value: "test" } }); 139 + vi.advanceTimersByTime(350); 140 + 141 + await waitFor(() => expect(searchPostsNetworkMock).toHaveBeenCalled()); 142 + 143 + fireEvent.keyDown(input, { key: "Escape" }); 144 + 145 + await waitFor(() => { 146 + expect(input).toHaveValue(""); 147 + }); 148 + }); 149 + 150 + it("displays error state when search fails", async () => { 151 + searchPostsNetworkMock.mockRejectedValue(new Error("Search failed")); 152 + 153 + render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 154 + 155 + const input = await screen.findByPlaceholderText("Search posts..."); 156 + fireEvent.input(input, { target: { value: "test" } }); 157 + vi.advanceTimersByTime(350); 158 + 159 + await waitFor(() => { 160 + expect(searchPostsNetworkMock).toHaveBeenCalled(); 161 + }); 162 + }); 163 + 164 + it("shows empty state when no results found", async () => { 165 + searchPostsMock.mockResolvedValue([]); 166 + 167 + render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 168 + 169 + const keywordButton = screen.getByRole("button", { name: /keyword/i }); 170 + fireEvent.click(keywordButton); 171 + 172 + const input = await screen.findByPlaceholderText("Search posts..."); 173 + fireEvent.input(input, { target: { value: "nonexistent" } }); 174 + vi.advanceTimersByTime(350); 175 + 176 + await waitFor(() => { 177 + expect(searchPostsMock).toHaveBeenCalled(); 178 + }); 179 + 180 + expect(await screen.findByText("No results found")).toBeInTheDocument(); 181 + }); 182 + });
+468
src/components/search/SearchPanel.tsx
··· 1 + import { Icon, SearchModeIcon } from "$/components/shared/Icon"; 2 + import { 3 + type LocalPostResult, 4 + type NetworkSearchResult, 5 + type SearchMode, 6 + searchPosts, 7 + searchPostsNetwork, 8 + } from "$/lib/api/search"; 9 + import type { ActiveSession } from "$/lib/types"; 10 + import { normalizeError } from "$/lib/utils/text"; 11 + import * as logger from "@tauri-apps/plugin-log"; 12 + import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 13 + import { Motion, Presence } from "solid-motionone"; 14 + import { SearchEmptyState } from "./SearchEmptyState"; 15 + import { SearchResultCard } from "./SearchResultCard"; 16 + import { SyncStatusPanel } from "./SyncStatusPanel"; 17 + 18 + const MODES: SearchMode[] = ["network", "keyword", "semantic", "hybrid"]; 19 + 20 + function ModeLabel(props: { mode: SearchMode }) { 21 + return ( 22 + <span class="flex items-center gap-1"> 23 + <SearchModeIcon mode={props.mode} class="text-base" /> 24 + <Switch> 25 + <Match when={props.mode === "network"}>Network</Match> 26 + <Match when={props.mode === "keyword"}>Keyword</Match> 27 + <Match when={props.mode === "semantic"}>Semantic</Match> 28 + <Match when={props.mode === "hybrid"}>Hybrid</Match> 29 + </Switch> 30 + </span> 31 + ); 32 + } 33 + 34 + type SearchPanelProps = { session: ActiveSession }; 35 + 36 + export function SearchPanel(props: SearchPanelProps) { 37 + const [mode, setMode] = createSignal<SearchMode>("network"); 38 + const [query, setQuery] = createSignal(""); 39 + const [results, setResults] = createSignal<LocalPostResult[]>([]); 40 + const [networkResults, setNetworkResults] = createSignal<NetworkSearchResult | null>(null); 41 + const [loading, setLoading] = createSignal(false); 42 + const [error, setError] = createSignal<string | null>(null); 43 + const [resultCount, setResultCount] = createSignal(0); 44 + const [hasSearched, setHasSearched] = createSignal(false); 45 + 46 + let searchInputRef: HTMLInputElement | undefined; 47 + let debounceTimer: ReturnType<typeof setTimeout> | undefined; 48 + 49 + const isLocalMode = createMemo(() => mode() !== "network"); 50 + 51 + async function performSearch(searchQuery: string, searchMode: SearchMode) { 52 + if (!searchQuery.trim()) { 53 + setResults([]); 54 + setNetworkResults(null); 55 + setResultCount(0); 56 + return; 57 + } 58 + 59 + setLoading(true); 60 + setError(null); 61 + 62 + try { 63 + if (searchMode === "network") { 64 + const response = await searchPostsNetwork(searchQuery, "top", 25); 65 + setNetworkResults(response); 66 + setResultCount(response.posts.length); 67 + } else { 68 + const response = await searchPosts(searchQuery, searchMode, 50); 69 + setResults(response); 70 + setResultCount(response.length); 71 + } 72 + setHasSearched(true); 73 + } catch (err) { 74 + const errorMsg = normalizeError(err); 75 + setError(errorMsg); 76 + logger.error("search failed", { keyValues: { query: searchQuery, mode: searchMode, error: errorMsg } }); 77 + } finally { 78 + setLoading(false); 79 + } 80 + } 81 + 82 + function handleInput(value: string) { 83 + setQuery(value); 84 + clearTimeout(debounceTimer); 85 + debounceTimer = setTimeout(() => { 86 + void performSearch(value, mode()); 87 + }, 300); 88 + } 89 + 90 + function handleModeChange(newMode: SearchMode) { 91 + setMode(newMode); 92 + if (query().trim()) { 93 + void performSearch(query(), newMode); 94 + } 95 + } 96 + 97 + function cycleMode() { 98 + const currentIndex = MODES.indexOf(mode()); 99 + const nextIndex = (currentIndex + 1) % MODES.length; 100 + handleModeChange(MODES[nextIndex]); 101 + } 102 + 103 + function clearSearch() { 104 + setQuery(""); 105 + setResults([]); 106 + setNetworkResults(null); 107 + setResultCount(0); 108 + setHasSearched(false); 109 + setError(null); 110 + searchInputRef?.focus(); 111 + } 112 + 113 + function handleKeyDown(event: KeyboardEvent) { 114 + if (event.key === "Tab" && !event.shiftKey && document.activeElement === searchInputRef) { 115 + event.preventDefault(); 116 + cycleMode(); 117 + } else if (event.key === "Escape" && query()) { 118 + clearSearch(); 119 + } 120 + } 121 + 122 + function handleGlobalKeyDown(event: KeyboardEvent) { 123 + if (event.key === "/" || ((event.metaKey || event.ctrlKey) && event.key === "f")) { 124 + const target = event.target as HTMLElement; 125 + if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") { 126 + event.preventDefault(); 127 + searchInputRef?.focus(); 128 + } 129 + } 130 + } 131 + 132 + onMount(() => { 133 + document.addEventListener("keydown", handleGlobalKeyDown); 134 + onCleanup(() => { 135 + document.removeEventListener("keydown", handleGlobalKeyDown); 136 + clearTimeout(debounceTimer); 137 + }); 138 + }); 139 + 140 + return ( 141 + <article class="grid min-h-0 grid-rows-[auto_auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 142 + <SearchHeader 143 + error={error()} 144 + hasSearched={hasSearched()} 145 + loading={loading()} 146 + mode={mode()} 147 + query={query()} 148 + resultCount={resultCount()} 149 + onModeChange={handleModeChange} 150 + onQueryChange={handleInput} 151 + inputRef={(el) => { 152 + searchInputRef = el; 153 + }} 154 + onKeyDown={handleKeyDown} 155 + onClear={clearSearch} /> 156 + 157 + <SyncStatusPanel did={props.session.did} /> 158 + 159 + <SearchViewport 160 + hasSearched={hasSearched()} 161 + isLocalMode={isLocalMode()} 162 + loading={loading()} 163 + localResults={results()} 164 + networkResults={networkResults()} 165 + query={query()} /> 166 + </article> 167 + ); 168 + } 169 + 170 + function SearchHeader( 171 + props: { 172 + error: string | null; 173 + hasSearched: boolean; 174 + inputRef: (el: HTMLInputElement) => void; 175 + loading: boolean; 176 + mode: SearchMode; 177 + onClear: () => void; 178 + onKeyDown: (event: KeyboardEvent) => void; 179 + onModeChange: (mode: SearchMode) => void; 180 + onQueryChange: (value: string) => void; 181 + query: string; 182 + resultCount: number; 183 + }, 184 + ) { 185 + return ( 186 + <header class="grid gap-4 px-6 pb-4 pt-6"> 187 + <SearchInput 188 + error={props.error} 189 + inputRef={props.inputRef} 190 + loading={props.loading} 191 + query={props.query} 192 + onClear={props.onClear} 193 + onKeyDown={props.onKeyDown} 194 + onQueryChange={props.onQueryChange} /> 195 + 196 + <div class="flex items-center justify-between"> 197 + <ModeSelector activeMode={props.mode} onModeChange={props.onModeChange} /> 198 + <span class="text-xs text-on-surface-variant"> 199 + <kbd class="rounded bg-white/10 px-1.5 py-0.5">Tab</kbd> to switch modes 200 + </span> 201 + </div> 202 + 203 + <Show when={props.hasSearched && !props.error}> 204 + <ResultCount count={props.resultCount} /> 205 + </Show> 206 + </header> 207 + ); 208 + } 209 + 210 + function ResultCount(props: { count: number }) { 211 + return ( 212 + <div class="flex items-center justify-between border-t border-white/5 pt-3"> 213 + <span class="text-sm text-on-surface-variant"> 214 + Found <span class="font-medium text-on-surface">{props.count}</span> results 215 + </span> 216 + </div> 217 + ); 218 + } 219 + 220 + function SearchInput( 221 + props: { 222 + error: string | null; 223 + inputRef: (el: HTMLInputElement) => void; 224 + loading: boolean; 225 + query: string; 226 + onClear: () => void; 227 + onKeyDown: (event: KeyboardEvent) => void; 228 + onQueryChange: (value: string) => void; 229 + }, 230 + ) { 231 + return ( 232 + <div class="relative"> 233 + <div class="absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant"> 234 + <Icon kind="search" class="text-lg" /> 235 + </div> 236 + 237 + <input 238 + ref={props.inputRef} 239 + type="text" 240 + value={props.query} 241 + placeholder="Search posts..." 242 + class="w-full rounded-2xl border-0 bg-black/40 py-3 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" 243 + onInput={(e) => props.onQueryChange(e.currentTarget.value)} 244 + onKeyDown={(e) => props.onKeyDown(e)} /> 245 + 246 + <div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-2"> 247 + <LoadingIndicator loading={props.loading} /> 248 + <ClearButton query={props.query} loading={props.loading} onClear={props.onClear} /> 249 + </div> 250 + </div> 251 + ); 252 + } 253 + 254 + function LoadingIndicator(props: { loading: boolean }) { 255 + return ( 256 + <Show when={props.loading}> 257 + <span class="flex items-center text-on-surface-variant"> 258 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 259 + </span> 260 + </Show> 261 + ); 262 + } 263 + 264 + function ClearButton(props: { query: string; loading: boolean; onClear: () => void }) { 265 + return ( 266 + <Show when={props.query && !props.loading}> 267 + <button 268 + type="button" 269 + onClick={() => props.onClear()} 270 + class="inline-flex items-center gap-1.5 rounded-lg border-0 bg-white/10 px-2 py-1 text-xs text-on-surface-variant transition hover:bg-white/20 hover:text-on-surface"> 271 + <kbd class="rounded bg-white/10 px-1">ESC</kbd> 272 + clear 273 + </button> 274 + </Show> 275 + ); 276 + } 277 + 278 + function ModeSelector(props: { activeMode: SearchMode; onModeChange: (mode: SearchMode) => void }) { 279 + const [indicatorStyle, setIndicatorStyle] = createSignal({ left: "0px", width: "0px" }); 280 + const [containerRef, setContainerRef] = createSignal<HTMLDivElement | undefined>(); 281 + 282 + createEffect(() => { 283 + const mode = props.activeMode; 284 + const ref = containerRef(); 285 + if (!ref) return; 286 + 287 + const buttons = ref.querySelectorAll("button"); 288 + const activeIndex = MODES.indexOf(mode); 289 + const activeButton = buttons[activeIndex]; 290 + if (!activeButton) return; 291 + 292 + const rect = activeButton.getBoundingClientRect(); 293 + const containerRect = ref.getBoundingClientRect(); 294 + 295 + setIndicatorStyle({ left: `${rect.left - containerRect.left}px`, width: `${rect.width}px` }); 296 + }); 297 + 298 + return ( 299 + <div ref={setContainerRef} class="relative flex gap-1 rounded-full bg-black/30 p-1"> 300 + <Motion.div 301 + class="absolute inset-y-1 rounded-full bg-surface-container-high shadow-[inset_0_0_0_1px_rgba(125,175,255,0.18)]" 302 + animate={indicatorStyle()} 303 + transition={{ duration: 0.2, easing: [0.25, 0.1, 0.25, 1] }} /> 304 + 305 + <For each={MODES}> 306 + {(searchMode) => ( 307 + <button 308 + type="button" 309 + aria-pressed={props.activeMode === searchMode} 310 + class="relative z-10 inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-colors duration-150" 311 + classList={{ 312 + "text-primary": props.activeMode === searchMode, 313 + "text-on-surface-variant hover:text-on-surface": props.activeMode !== searchMode, 314 + }} 315 + onClick={() => props.onModeChange(searchMode)}> 316 + <ModeLabel mode={searchMode} /> 317 + </button> 318 + )} 319 + </For> 320 + </div> 321 + ); 322 + } 323 + 324 + function SearchViewport( 325 + props: { 326 + hasSearched: boolean; 327 + isLocalMode: boolean; 328 + loading: boolean; 329 + localResults: LocalPostResult[]; 330 + networkResults: NetworkSearchResult | null; 331 + query: string; 332 + }, 333 + ) { 334 + return ( 335 + <div class="min-h-0 overflow-y-auto px-3 pb-3"> 336 + <Show when={props.loading} fallback={<SearchState {...props} />}> 337 + <div class="grid gap-2 py-1"> 338 + <For each={Array.from({ length: 5 })}>{() => <SearchSkeleton />}</For> 339 + </div> 340 + </Show> 341 + </div> 342 + ); 343 + } 344 + 345 + function SearchState( 346 + props: { 347 + hasSearched: boolean; 348 + isLocalMode: boolean; 349 + loading: boolean; 350 + localResults: LocalPostResult[]; 351 + networkResults: NetworkSearchResult | null; 352 + query: string; 353 + }, 354 + ) { 355 + return ( 356 + <Presence> 357 + <Switch> 358 + <Match when={!props.hasSearched && !props.query}> 359 + <EmptyStateView reason="initial" /> 360 + </Match> 361 + 362 + <Match when={props.isLocalMode && props.localResults.length === 0}> 363 + <EmptyStateView reason="no-results" /> 364 + </Match> 365 + 366 + <Match when={!props.isLocalMode && props.networkResults?.posts.length === 0}> 367 + <EmptyStateView reason="no-results" /> 368 + </Match> 369 + 370 + <Match when={props.isLocalMode}> 371 + <LocalResultsList results={props.localResults} /> 372 + </Match> 373 + 374 + <Match when={!props.isLocalMode && props.networkResults}> 375 + <NetworkResultsList results={props.networkResults} /> 376 + </Match> 377 + </Switch> 378 + </Presence> 379 + ); 380 + } 381 + 382 + function EmptyStateView(props: { reason: "initial" | "no-results" }) { 383 + return ( 384 + <Motion.div 385 + class="grid place-items-center px-6 py-16" 386 + initial={{ opacity: 0 }} 387 + animate={{ opacity: 1 }} 388 + exit={{ opacity: 0 }} 389 + transition={{ duration: 0.15 }}> 390 + <SearchEmptyState reason={props.reason} /> 391 + </Motion.div> 392 + ); 393 + } 394 + 395 + function LocalResultsList(props: { results: LocalPostResult[] }) { 396 + return ( 397 + <Motion.div 398 + class="grid gap-2" 399 + initial={{ opacity: 0 }} 400 + animate={{ opacity: 1 }} 401 + exit={{ opacity: 0 }} 402 + transition={{ duration: 0.15 }}> 403 + <div class="grid gap-2" role="list"> 404 + <For each={props.results}> 405 + {(result, index) => ( 406 + <Motion.div 407 + initial={{ opacity: 0, y: -6 }} 408 + animate={{ opacity: 1, y: 0 }} 409 + transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }} 410 + role="listitem"> 411 + <SearchResultCard 412 + authorHandle={result.author_handle} 413 + source={result.source} 414 + text={result.text} 415 + createdAt={result.created_at} 416 + isSemanticMatch={false} /> 417 + </Motion.div> 418 + )} 419 + </For> 420 + </div> 421 + </Motion.div> 422 + ); 423 + } 424 + 425 + function NetworkResultsList(props: { results: NetworkSearchResult | null }) { 426 + return ( 427 + <Motion.div 428 + class="grid gap-2" 429 + initial={{ opacity: 0 }} 430 + animate={{ opacity: 1 }} 431 + exit={{ opacity: 0 }} 432 + transition={{ duration: 0.15 }}> 433 + <div class="grid gap-2" role="list"> 434 + <For each={props.results?.posts ?? []}> 435 + {(post, index) => ( 436 + <Motion.div 437 + initial={{ opacity: 0, y: -6 }} 438 + animate={{ opacity: 1, y: 0 }} 439 + transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }} 440 + role="listitem"> 441 + <SearchResultCard 442 + authorHandle={post.author.handle} 443 + source="network" 444 + text={typeof post.record.text === "string" ? post.record.text : ""} 445 + createdAt={post.indexedAt} 446 + likeCount={post.likeCount ?? 0} 447 + replyCount={post.replyCount ?? 0} 448 + isSemanticMatch={false} /> 449 + </Motion.div> 450 + )} 451 + </For> 452 + </div> 453 + </Motion.div> 454 + ); 455 + } 456 + 457 + function SearchSkeleton() { 458 + return ( 459 + <div class="flex animate-pulse items-start gap-4 rounded-2xl bg-surface px-4 py-4" aria-hidden="true"> 460 + <div class="h-10 w-10 shrink-0 rounded-full bg-white/5" /> 461 + <div class="min-w-0 flex-1 space-y-2"> 462 + <div class="h-4 w-48 rounded-full bg-white/5" /> 463 + <div class="h-3 w-full rounded-full bg-white/5" /> 464 + <div class="h-3 w-2/3 rounded-full bg-white/5" /> 465 + </div> 466 + </div> 467 + ); 468 + }
+158
src/components/search/SearchResultCard.tsx
··· 1 + import { formatRelativeTime } from "$/lib/feeds"; 2 + import { escapeForRegex } from "$/lib/utils/text"; 3 + import { createMemo, type JSX, Show } from "solid-js"; 4 + import { Icon } from "../shared/Icon"; 5 + 6 + type SearchResultCardProps = { 7 + authorHandle: string; 8 + source: "like" | "bookmark" | "network"; 9 + text: string; 10 + createdAt: string; 11 + likeCount?: number; 12 + replyCount?: number; 13 + isSemanticMatch?: boolean; 14 + query?: string; 15 + }; 16 + 17 + export function SearchResultCard(props: SearchResultCardProps) { 18 + const avatarLabel = createMemo(() => props.authorHandle.slice(0, 1).toUpperCase() || "?"); 19 + 20 + const formattedTime = createMemo(() => formatRelativeTime(props.createdAt)); 21 + 22 + const sourceLabel = createMemo(() => { 23 + switch (props.source) { 24 + case "like": { 25 + return "Liked"; 26 + } 27 + case "bookmark": { 28 + return "Bookmarked"; 29 + } 30 + default: { 31 + return null; 32 + } 33 + } 34 + }); 35 + 36 + const highlightedText = createMemo(() => { 37 + if (!props.query || !props.text) { 38 + return props.text; 39 + } 40 + 41 + const parts = props.text.split(new RegExp(`(${escapeForRegex(props.query)})`, "gi")); 42 + return parts.map((part) => { 43 + if (part.toLowerCase() === props.query?.toLowerCase()) { 44 + return <mark class="rounded bg-primary/20 px-0.5 text-primary">{part}</mark>; 45 + } 46 + return part; 47 + }); 48 + }); 49 + 50 + return ( 51 + <article 52 + class="group cursor-pointer rounded-2xl bg-surface px-5 py-4 transition-colors duration-150 hover:bg-white/3" 53 + role="article"> 54 + <CardContent 55 + avatarLabel={avatarLabel()} 56 + authorHandle={props.authorHandle} 57 + time={formattedTime()} 58 + isSemantic={props.isSemanticMatch} 59 + text={highlightedText()} 60 + likes={props.likeCount} 61 + replies={props.replyCount} 62 + sourceLabel={sourceLabel()} /> 63 + </article> 64 + ); 65 + } 66 + 67 + function CardContent( 68 + props: { 69 + avatarLabel: string; 70 + authorHandle: string; 71 + time: string; 72 + isSemantic?: boolean; 73 + text: string | (string | JSX.Element)[]; 74 + likes?: number; 75 + replies?: number; 76 + sourceLabel: string | null; 77 + }, 78 + ) { 79 + return ( 80 + <div class="flex gap-3"> 81 + <Avatar label={props.avatarLabel} /> 82 + <div class="min-w-0 flex-1"> 83 + <CardHeader handle={props.authorHandle} time={props.time} isSemantic={props.isSemantic} /> 84 + <TextContent text={props.text} /> 85 + <CardFooter likes={props.likes} replies={props.replies} sourceLabel={props.sourceLabel} /> 86 + </div> 87 + </div> 88 + ); 89 + } 90 + 91 + function Avatar(props: { label: string }) { 92 + const base = "relative mt-0.5 h-10 w-10 shrink-0 overflow-hidden rounded-full "; 93 + const gradient = "bg-[linear-gradient(135deg,rgba(125,175,255,0.9),rgba(0,115,222,0.72))] "; 94 + const shadow = "shadow-[0_0_0_2px_rgba(14,14,14,1),0_0_0_3px_rgba(125,175,255,0.28)]"; 95 + 96 + return ( 97 + <div class={base + gradient + shadow}> 98 + <div class="flex h-full w-full items-center justify-center text-sm font-semibold text-on-primary-fixed"> 99 + {props.label} 100 + </div> 101 + </div> 102 + ); 103 + } 104 + 105 + function CardHeader(props: { handle: string; time: string; isSemantic?: boolean }) { 106 + return ( 107 + <header class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1"> 108 + <span class="wrap-break-word text-sm font-semibold text-on-surface">@{props.handle.replace(/^@/, "")}</span> 109 + <span class="text-xs text-on-surface-variant">{props.time}</span> 110 + <SemanticBadge isSemantic={props.isSemantic} /> 111 + </header> 112 + ); 113 + } 114 + 115 + function SemanticBadge(props: { isSemantic?: boolean }) { 116 + return ( 117 + <Show when={props.isSemantic}> 118 + <span class="rounded-full bg-primary/15 px-2 py-0.5 text-xs text-primary">Semantic match</span> 119 + </Show> 120 + ); 121 + } 122 + 123 + function TextContent(props: { text: string | (string | JSX.Element)[] }) { 124 + return ( 125 + <p class="m-0 whitespace-pre-wrap wrap-break-word text-sm leading-relaxed text-on-secondary-container"> 126 + {props.text} 127 + </p> 128 + ); 129 + } 130 + 131 + function CardFooter(props: { likes?: number; replies?: number; sourceLabel: string | null }) { 132 + return ( 133 + <footer class="mt-3 flex min-w-0 flex-wrap items-center gap-3"> 134 + <Show when={typeof props.likes === "number"}> 135 + <StatBadge kind="like" value={props.likes} label="likes" /> 136 + </Show> 137 + 138 + <Show when={typeof props.replies === "number"}> 139 + <StatBadge kind="reply" value={props.replies} label="replies" /> 140 + </Show> 141 + 142 + <Show when={props.sourceLabel}> 143 + {(label) => <span class="rounded-full bg-white/10 px-2 py-0.5 text-xs text-on-surface-variant">{label()}</span>} 144 + </Show> 145 + </footer> 146 + ); 147 + } 148 + 149 + function StatBadge(props: { kind: "like" | "reply"; value?: number; label: string }) { 150 + return ( 151 + <span class="inline-flex items-center gap-1.5 text-xs text-on-surface-variant"> 152 + <Show when={props.kind === "like"} fallback={<Icon kind="quote" class="text-xs text-on-surface-variant" />}> 153 + <Icon kind="heart" class="text-xs text-on-surface-variant" /> 154 + </Show> 155 + {props.value} {props.label} 156 + </span> 157 + ); 158 + }
+135
src/components/search/SyncStatusPanel.tsx
··· 1 + import { getSyncStatus, syncPosts, type SyncStatus } from "$/lib/api/search"; 2 + import * as logger from "@tauri-apps/plugin-log"; 3 + import { createSignal, onMount, Show } from "solid-js"; 4 + import { Motion } from "solid-motionone"; 5 + 6 + type SyncStatusPanelProps = { did: string }; 7 + 8 + export function SyncStatusPanel(props: SyncStatusPanelProps) { 9 + const [syncStatus, setSyncStatus] = createSignal<SyncStatus[]>([]); 10 + const [isSyncing, setIsSyncing] = createSignal(false); 11 + 12 + async function loadSyncStatus() { 13 + try { 14 + const status = await getSyncStatus(props.did); 15 + setSyncStatus(status); 16 + } catch (error) { 17 + logger.error("failed to load sync status", { keyValues: { error: String(error) } }); 18 + } 19 + } 20 + 21 + async function handleSync() { 22 + setIsSyncing(true); 23 + try { 24 + await syncPosts(props.did, "like"); 25 + await syncPosts(props.did, "bookmark"); 26 + await loadSyncStatus(); 27 + } catch (error) { 28 + logger.error("sync failed", { keyValues: { error: String(error) } }); 29 + } finally { 30 + setIsSyncing(false); 31 + } 32 + } 33 + 34 + onMount(() => { 35 + void loadSyncStatus(); 36 + 37 + const interval = setInterval(() => { 38 + void loadSyncStatus(); 39 + }, 60_000); 40 + 41 + return () => clearInterval(interval); 42 + }); 43 + 44 + const totalPosts = () => syncStatus().reduce((sum, s) => sum + (s.post_count ?? 0), 0); 45 + 46 + const lastSyncTime = () => { 47 + const times = syncStatus().map((s) => s.last_synced_at).filter(Boolean) as string[]; 48 + if (times.length === 0) return null; 49 + const latest = times.toSorted().toReversed()[0]; 50 + return formatRelativeTime(latest); 51 + }; 52 + 53 + return ( 54 + <Motion.div 55 + class="border-b border-white/5 px-6 py-3" 56 + initial={{ opacity: 0, height: 0 }} 57 + animate={{ opacity: 1, height: "auto" }} 58 + exit={{ opacity: 0, height: 0 }} 59 + transition={{ duration: 0.2 }}> 60 + <div class="flex items-center justify-between"> 61 + <StatusInfo isSyncing={isSyncing()} totalPosts={totalPosts()} lastSync={lastSyncTime()} /> 62 + <SyncButton isSyncing={isSyncing()} onSync={handleSync} /> 63 + </div> 64 + </Motion.div> 65 + ); 66 + } 67 + 68 + function StatusInfo(props: { isSyncing: boolean; totalPosts: number; lastSync: string | null }) { 69 + return ( 70 + <div class="flex items-center gap-3"> 71 + <div class="flex items-center gap-2"> 72 + <StatusIndicator isSyncing={props.isSyncing} /> 73 + <span class="text-sm font-medium text-on-surface">{props.isSyncing ? "Syncing..." : "Active"}</span> 74 + </div> 75 + 76 + <Show when={props.totalPosts > 0}> 77 + <span class="text-xs text-on-surface-variant"> 78 + <span class="font-medium text-primary">{props.totalPosts}</span> posts indexed 79 + </span> 80 + </Show> 81 + 82 + <Show when={props.lastSync}> 83 + {(time) => <span class="text-xs text-on-surface-variant">· Last sync: {time()}</span>} 84 + </Show> 85 + </div> 86 + ); 87 + } 88 + 89 + function StatusIndicator(props: { isSyncing: boolean }) { 90 + return ( 91 + <Show when={props.isSyncing} fallback={<span class="flex h-2 w-2 rounded-full bg-green-500" />}> 92 + <span class="flex h-2 w-2 animate-pulse rounded-full bg-primary" /> 93 + </Show> 94 + ); 95 + } 96 + 97 + function SyncButton(props: { isSyncing: boolean; onSync: () => void }) { 98 + return ( 99 + <button 100 + type="button" 101 + onClick={() => props.onSync()} 102 + disabled={props.isSyncing} 103 + class="inline-flex items-center gap-2 rounded-lg border-0 bg-white/5 px-3 py-1.5 text-xs font-medium text-on-surface-variant transition hover:bg-white/10 hover:text-on-surface disabled:cursor-not-allowed disabled:opacity-50"> 104 + <span class="flex items-center"> 105 + <i classList={{ "i-ri-refresh-line": !props.isSyncing, "i-ri-loader-4-line animate-spin": props.isSyncing }} /> 106 + </span> 107 + <Show when={props.isSyncing} fallback={"Sync now"}>Syncing...</Show> 108 + </button> 109 + ); 110 + } 111 + 112 + function formatRelativeTime(value: string) { 113 + const timestamp = new Date(value).getTime(); 114 + if (Number.isNaN(timestamp)) { 115 + return ""; 116 + } 117 + 118 + const deltaSeconds = Math.round((timestamp - Date.now()) / 1000); 119 + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); 120 + const ranges = [ 121 + ["year", 60 * 60 * 24 * 365], 122 + ["month", 60 * 60 * 24 * 30], 123 + ["day", 60 * 60 * 24], 124 + ["hour", 60 * 60], 125 + ["minute", 60], 126 + ] as const; 127 + 128 + for (const [unit, seconds] of ranges) { 129 + if (Math.abs(deltaSeconds) >= seconds) { 130 + return formatter.format(Math.round(deltaSeconds / seconds), unit); 131 + } 132 + } 133 + 134 + return formatter.format(deltaSeconds, "second"); 135 + }
+3 -6
src/components/shared/ErrorToast.tsx
··· 1 1 import { Show } from "solid-js"; 2 2 import { Motion, Presence } from "solid-motionone"; 3 + import { Icon } from "./Icon"; 3 4 4 5 type ErrorToastProps = { message: string | null; onDismiss: () => void }; 5 6 ··· 16 17 animate={{ opacity: 1, y: 0, scale: 1 }} 17 18 exit={{ opacity: 0, y: 16, scale: 0.94 }} 18 19 transition={{ duration: 0.2 }}> 19 - <span class="flex items-center text-error" aria-hidden="true"> 20 - <i class="i-ri-error-warning-line" /> 21 - </span> 20 + <Icon kind="danger" aria-hidden="true" class="text-error" /> 22 21 <p class="m-0 text-[0.875rem] text-on-surface">{message()}</p> 23 22 <button 24 23 type="button" 25 24 class="cursor-pointer rounded-full border-0 bg-transparent p-[0.35rem] text-inherit hover:bg-surface-bright" 26 25 onClick={props.onDismiss}> 27 - <span class="flex items-center" aria-hidden="true"> 28 - <i class="i-ri-close-line" /> 29 - </span> 26 + <Icon kind="close" aria-hidden="true" /> 30 27 <span class="sr-only">Dismiss error</span> 31 28 </button> 32 29 </Motion.div>
+60 -1
src/components/shared/Icon.tsx
··· 1 + import type { SearchMode } from "$/lib/api/search"; 1 2 import type { ExplorerTargetKind } from "$/lib/api/types/explorer"; 2 3 import { type JSX, Match, splitProps, Switch } from "solid-js"; 3 4 ··· 19 20 | "quote" 20 21 | "close" 21 22 | "folder" 22 - | "file"; 23 + | "file" 24 + | "like" 25 + | "heart" 26 + | "db" 27 + | "complete" 28 + | "explore" 29 + | "compass" 30 + | "danger" 31 + | "repost" 32 + | "reply" 33 + | "follow"; 23 34 24 35 type IconProps = JSX.HTMLAttributes<HTMLSpanElement> & { 25 36 class?: string; ··· 91 102 <Match when={local.kind === "file"}> 92 103 <i class="i-ri-file-line" /> 93 104 </Match> 105 + <Match when={local.kind === "like"}> 106 + <i class="i-ri-thumb-up-line" /> 107 + </Match> 108 + <Match when={local.kind === "heart"}> 109 + <i class="i-ri-heart-line" /> 110 + </Match> 111 + <Match when={local.kind === "db"}> 112 + <i class="i-ri-database-2-line" /> 113 + </Match> 114 + <Match when={local.kind === "explore" || local.kind === "compass"}> 115 + <i class="i-ri-compass-discover-line" /> 116 + </Match> 117 + <Match when={local.kind === "complete"}> 118 + <i class="i-ri-check-double-line" /> 119 + </Match> 120 + <Match when={local.kind === "danger"}> 121 + <i class="i-ri-error-warning-line" /> 122 + </Match> 123 + <Match when={local.kind === "reply"}> 124 + <i class="i-ri-chat-3-line" /> 125 + </Match> 126 + <Match when={local.kind === "repost"}> 127 + <i class="i-ri-repeat-2-line" /> 128 + </Match> 129 + <Match when={local.kind === "follow"}> 130 + <i class="i-ri-user-add-line" /> 131 + </Match> 94 132 </Switch> 95 133 </span> 96 134 ); ··· 137 175 </span> 138 176 ); 139 177 } 178 + 179 + export function SearchModeIcon(props: { mode: SearchMode; class?: string }) { 180 + return ( 181 + <span class="flex items-center justify-center" classList={{ [props.class ?? ""]: !!props.class }}> 182 + <Switch> 183 + <Match when={props.mode === "network"}> 184 + <i class="i-ri-global-line" /> 185 + </Match> 186 + <Match when={props.mode === "keyword"}> 187 + <i class="i-ri-search-line" /> 188 + </Match> 189 + <Match when={props.mode === "semantic"}> 190 + <i class="i-ri-bubble-chart-line" /> 191 + </Match> 192 + <Match when={props.mode === "hybrid"}> 193 + <i class="i-ri-stack-line" /> 194 + </Match> 195 + </Switch> 196 + </span> 197 + ); 198 + }
+95
src/lib/api/search.ts
··· 1 + import type { PostView } from "$/lib/types"; 2 + import { invoke } from "@tauri-apps/api/core"; 3 + 4 + export type SearchMode = "network" | "keyword" | "semantic" | "hybrid"; 5 + 6 + export type NetworkSearchResult = { cursor?: string | null; hitsTotal?: number | null; posts: PostView[] }; 7 + 8 + export type ActorSearchResult = { 9 + cursor?: string | null; 10 + actors: { did: string; handle: string; displayName?: string | null; avatar?: string | null }[]; 11 + }; 12 + 13 + export type StarterPackSearchResult = { 14 + cursor?: string | null; 15 + starterPacks: { 16 + uri: string; 17 + cid: string; 18 + record: { name: string; description?: string; createdAt: string }; 19 + creator: { did: string; handle: string; displayName?: string | null; avatar?: string | null }; 20 + indexedAt: string; 21 + }[]; 22 + }; 23 + 24 + export type LocalPostResult = { 25 + uri: string; 26 + cid: string; 27 + author_did: string; 28 + author_handle: string; 29 + text: string; 30 + created_at: string; 31 + indexed_at: string; 32 + source: "like" | "bookmark"; 33 + score?: number; 34 + }; 35 + 36 + export type SyncStatus = { 37 + did: string; 38 + source: "like" | "bookmark"; 39 + cursor?: string | null; 40 + last_synced_at?: string | null; 41 + post_count?: number; 42 + }; 43 + 44 + export type EmbeddingsConfig = { 45 + enabled: boolean; 46 + model_name: string; 47 + dimensions: number; 48 + downloaded: boolean; 49 + download_progress?: number; 50 + }; 51 + 52 + export function searchPostsNetwork( 53 + query: string, 54 + sort?: "top" | "latest", 55 + limit?: number, 56 + cursor?: string | null, 57 + ): Promise<NetworkSearchResult> { 58 + return invoke("search_posts_network", { query, sort: sort ?? null, limit: limit ?? null, cursor: cursor ?? null }); 59 + } 60 + 61 + export function searchPosts(query: string, mode: SearchMode, limit: number): Promise<LocalPostResult[]> { 62 + return invoke("search_posts", { query, mode, limit }); 63 + } 64 + 65 + export function searchActors(query: string, limit?: number, cursor?: string | null): Promise<ActorSearchResult> { 66 + return invoke("search_actors", { query, limit: limit ?? null, cursor: cursor ?? null }); 67 + } 68 + 69 + export function searchStarterPacks( 70 + query: string, 71 + limit?: number, 72 + cursor?: string | null, 73 + ): Promise<StarterPackSearchResult> { 74 + return invoke("search_starter_packs", { query, limit: limit ?? null, cursor: cursor ?? null }); 75 + } 76 + 77 + export function syncPosts(did: string, source: "like" | "bookmark"): Promise<SyncStatus> { 78 + return invoke("sync_posts", { did, source }); 79 + } 80 + 81 + export function getSyncStatus(did: string): Promise<SyncStatus[]> { 82 + return invoke("get_sync_status", { did }); 83 + } 84 + 85 + export function embedPendingPosts(): Promise<number> { 86 + return invoke("embed_pending_posts"); 87 + } 88 + 89 + export function reindexEmbeddings(): Promise<number> { 90 + return invoke("reindex_embeddings"); 91 + } 92 + 93 + export function setEmbeddingsEnabled(enabled: boolean): Promise<void> { 94 + return invoke("set_embeddings_enabled", { enabled }); 95 + }
+2 -21
src/router.tsx
··· 9 9 } from "@solidjs/router"; 10 10 import { type Component, createEffect, type JSX, type ParentProps, Show } from "solid-js"; 11 11 import { ExplorerPanel } from "./components/explorer/ExplorerPanel"; 12 + import { SearchPanel } from "./components/search/SearchPanel"; 12 13 import { buildThreadRoute, decodeThreadRouteUri, TIMELINE_ROUTE } from "./lib/feeds"; 13 14 import type { ActiveSession } from "./lib/types"; 14 15 ··· 86 87 87 88 const SearchRoute = () => ( 88 89 <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 89 - {() => ( 90 - <FeaturePlaceholder 91 - eyebrow="Search" 92 - title="Local search is on deck." 93 - description="Keyword, semantic, and hybrid search routes are wired now. This view stays behind auth until the indexed search workflow lands." /> 94 - )} 90 + {(session) => <SearchPanel session={session} />} 95 91 </ProtectedRouteView> 96 92 ); 97 93 ··· 188 184 </div> 189 185 ); 190 186 } 191 - 192 - function FeaturePlaceholder(props: { description: string; eyebrow: string; title: string }) { 193 - return ( 194 - <article class="grid min-h-168 content-start gap-8 rounded-4xl bg-[linear-gradient(160deg,rgba(255,255,255,0.03),rgba(255,255,255,0.015))] p-8 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 195 - <div class="flex items-baseline justify-between gap-4"> 196 - <p class="overline-copy text-sm text-primary">{props.eyebrow}</p> 197 - <p class="overline-copy text-xs text-on-surface-variant">Authenticated route</p> 198 - </div> 199 - <div class="grid max-w-xl gap-4"> 200 - <h1 class="m-0 text-[clamp(2.6rem,5vw,4.3rem)] tracking-tighter text-on-surface">{props.title}</h1> 201 - <p class="m-0 max-w-136 text-base leading-7 text-on-secondary-container">{props.description}</p> 202 - </div> 203 - </article> 204 - ); 205 - }