BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { Icon } from "$/components/shared/Icon";
2import { Match, Show, Switch } from "solid-js";
3import type { EmptyStateReason } from "./types";
4
5type SearchEmptyStateScope = "local" | "network" | "profiles";
6
7type SearchEmptyStateProps = { reason: EmptyStateReason | "no-sync"; scope?: SearchEmptyStateScope };
8
9export function SearchEmptyState(props: SearchEmptyStateProps) {
10 return (
11 <div class="text-center">
12 <EmptyStateVisual reason={props.reason} />
13 <EmptyStateContent reason={props.reason} scope={props.scope ?? "local"} />
14 </div>
15 );
16}
17
18function EmptyStateVisual(props: { reason: EmptyStateReason | "no-sync" }) {
19 return (
20 <Show when={props.reason === "no-sync"} fallback={<EmptyStateIcon />}>
21 <NoSyncIllustration />
22 </Show>
23 );
24}
25
26function EmptyStateIcon() {
27 return (
28 <div class="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full bg-white/5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]">
29 <Icon kind="search" class="text-3xl text-on-surface-variant" />
30 </div>
31 );
32}
33
34function NoSyncIllustration() {
35 return (
36 <div
37 data-testid="no-sync-illustration"
38 class="relative mx-auto mb-6 h-40 w-full max-w-xs overflow-hidden rounded-4xl bg-white/2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]">
39 <div class="absolute inset-x-6 top-5 h-16 rounded-[1.25rem] bg-primary/10 blur-2xl" />
40 <div class="absolute left-5 top-7 w-26 rounded-[1.4rem] bg-surface-container p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]">
41 <div class="mb-2 flex items-center gap-2">
42 <span class="flex h-8 w-8 items-center justify-center rounded-xl bg-primary/14 text-primary">
43 <Icon kind="bookmark" class="text-base" />
44 </span>
45 <span class="h-2.5 w-12 rounded-full bg-white/8" />
46 </div>
47 <div class="grid gap-1.5">
48 <span class="h-2 rounded-full bg-white/7" />
49 <span class="h-2 w-4/5 rounded-full bg-white/5" />
50 </div>
51 </div>
52
53 <div class="absolute right-5 top-10 w-28 rounded-[1.4rem] bg-surface-container-high p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]">
54 <div class="mb-3 flex items-center justify-between">
55 <span class="flex h-8 w-8 items-center justify-center rounded-xl bg-white/8 text-on-surface-variant">
56 <Icon kind="db" class="text-base" />
57 </span>
58 <span class="rounded-full bg-white/8 px-2 py-0.5 text-[0.62rem] uppercase tracking-[0.12em] text-on-surface-variant">
59 local
60 </span>
61 </div>
62 <div class="grid gap-1.5">
63 <span class="h-2 rounded-full bg-white/7" />
64 <span class="h-2 w-3/4 rounded-full bg-primary/18" />
65 <span class="h-2 w-2/3 rounded-full bg-white/5" />
66 </div>
67 </div>
68
69 <div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-full bg-black/35 px-3 py-1.5 text-[0.68rem] text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]">
70 <Icon kind="refresh" class="text-primary" />
71 <span>Run a sync to fill local search</span>
72 </div>
73 </div>
74 );
75}
76
77function EmptyStateContent(props: { reason: EmptyStateReason | "no-sync"; scope: SearchEmptyStateScope }) {
78 return (
79 <Switch>
80 <Match when={props.reason === "initial"}>
81 <InitialContent scope={props.scope} />
82 </Match>
83
84 <Match when={props.reason === "no-results"}>
85 <NoResultsContent scope={props.scope} />
86 </Match>
87
88 <Match when={props.reason === "no-sync"}>
89 <NoSyncContent />
90 </Match>
91
92 <Match when={props.reason === "error"}>
93 <ErrorContent scope={props.scope} />
94 </Match>
95 </Switch>
96 );
97}
98
99function InitialContent(props: { scope: SearchEmptyStateScope }) {
100 return (
101 <>
102 <Switch>
103 <Match when={props.scope === "profiles"}>
104 <h3 class="mb-1 text-base font-medium text-on-surface">Search people across Bluesky</h3>
105 <p class="m-0 text-sm text-on-surface-variant">
106 Type a handle or display name above to find profiles and jump directly into their profile view.
107 </p>
108 </Match>
109 <Match when={props.scope === "network"}>
110 <h3 class="mb-1 text-base font-medium text-on-surface">Search public posts across the network</h3>
111 <p class="m-0 text-sm text-on-surface-variant">
112 Type a query above to search Bluesky directly without relying on your local index.
113 </p>
114 </Match>
115 <Match when={props.scope === "local"}>
116 <h3 class="mb-1 text-base font-medium text-on-surface">Search your saved & liked posts</h3>
117 <p class="m-0 text-sm text-on-surface-variant">
118 Type a query above to search through the posts you liked or bookmarked.
119 </p>
120 </Match>
121 </Switch>
122 <KeyboardShortcuts />
123 </>
124 );
125}
126
127function KeyboardShortcuts() {
128 return (
129 <div class="my-4 space-y-2 flex items-center justify-center flex-col text-xs text-on-surface-variant/60">
130 <div class="flex items-center gap-2">
131 <kbd class="rounded bg-white/10 px-1.5 py-0.5">/</kbd>
132 Focus search from anywhere
133 </div>
134 <div class="flex items-center gap-2">
135 <kbd class="rounded bg-white/10 px-1.5 py-0.5">Tab</kbd>
136 Cycle search modes
137 </div>
138 <div class="flex items-center gap-2">
139 <kbd class="rounded bg-white/10 px-1.5 py-0.5">↑↓</kbd>
140 Navigate profile suggestions
141 </div>
142 </div>
143 );
144}
145
146function NoResultsContent(props: { scope: SearchEmptyStateScope }) {
147 return (
148 <>
149 <h3 class="mb-1 text-base font-medium text-on-surface">
150 {props.scope === "profiles" ? "No profiles found" : "No results found"}
151 </h3>
152 <Switch>
153 <Match when={props.scope === "profiles"}>
154 <p class="m-0 text-sm text-on-surface-variant">
155 Try a broader handle fragment, a display name, or select one of the suggested profiles as you type.
156 </p>
157 </Match>
158 <Match when={props.scope === "network"}>
159 <p class="m-0 text-sm text-on-surface-variant">
160 Try a broader query or switch to local search if you want to search your synced posts instead.
161 </p>
162 </Match>
163 <Match when={props.scope === "local"}>
164 <p class="m-0 text-sm text-on-surface-variant">
165 Try adjusting your search terms or switch to a different search mode.
166 </p>
167 </Match>
168 </Switch>
169 </>
170 );
171}
172
173function NoSyncContent() {
174 return (
175 <>
176 <h3 class="mb-1 text-base font-medium text-on-surface">No posts synced yet</h3>
177 <p class="m-0 text-sm text-on-surface-variant">
178 Sync your liked and bookmarked posts to build the local index for keyword search now, then optionally unlock
179 semantic search later.
180 </p>
181 </>
182 );
183}
184
185function ErrorContent(props: { scope: SearchEmptyStateScope }) {
186 return (
187 <>
188 <h3 class="mb-1 text-base font-medium text-on-surface">
189 {props.scope === "profiles" ? "Profile search failed" : "Search failed"}
190 </h3>
191 <Switch>
192 <Match when={props.scope === "profiles"}>
193 <p class="m-0 text-sm text-on-surface-variant">
194 The profile lookup did not complete. Retry the query or open a suggested profile if it appears.
195 </p>
196 </Match>
197 <Match when={props.scope === "network"}>
198 <p class="m-0 text-sm text-on-surface-variant">
199 The network request did not complete. Retry the query or switch to local search while the network recovers.
200 </p>
201 </Match>
202 <Match when={props.scope === "local"}>
203 <p class="m-0 text-sm text-on-surface-variant">
204 The local index request did not complete. Retry the query or sync again if your index is stale.
205 </p>
206 </Match>
207 </Switch>
208 </>
209 );
210}