BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { Icon, LoadingIcon } from "$/components/shared/Icon";
2import { SearchController } from "$/lib/api/search";
3import type { SyncStatus } from "$/lib/api/types/search";
4import { formatRelativeTime } from "$/lib/utils/text";
5import * as logger from "@tauri-apps/plugin-log";
6import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js";
7import { Motion, Presence } from "solid-motionone";
8import { PostCount } from "../shared/PostCount";
9
10function SourceStatusRow(
11 props: { count: number; cursor?: string | null; isActive: boolean; source: "like" | "bookmark" },
12) {
13 const label = createMemo(() => (props.source === "like" ? "Liked posts" : "Bookmarked posts"));
14
15 return (
16 <div class="grid gap-1.5">
17 <div class="flex items-center justify-between gap-3 text-xs text-on-surface-variant">
18 <span>{label()}</span>
19 <span>{props.count} synced</span>
20 </div>
21
22 <div class="h-1.5 overflow-hidden rounded-full bg-white/8">
23 <div
24 class="h-full rounded-full bg-linear-to-r from-primary to-primary-dim transition-opacity"
25 classList={{ "animate-pulse": props.isActive }}
26 style={{ width: props.count > 0 ? "100%" : "0%" }} />
27 </div>
28
29 <Show when={props.cursor}>
30 <p class="m-0 text-[0.68rem] text-on-surface-variant/70">Resume cursor saved for interrupted sync recovery.</p>
31 </Show>
32 </div>
33 );
34}
35
36function ReindexButton(props: { isSyncing: boolean; isReindexing: boolean; onReindex: () => void }) {
37 return (
38 <button
39 type="button"
40 onClick={() => void props.onReindex()}
41 disabled={props.isSyncing || props.isReindexing}
42 class="inline-flex items-center gap-2 rounded-xl border-0 bg-white/6 px-3 py-2 text-xs font-medium text-on-surface-variant transition hover:bg-white/10 hover:text-on-surface disabled:cursor-not-allowed disabled:opacity-50">
43 <LoadingIcon isLoading={props.isReindexing} class="text-base" fallback={<Icon kind="refresh" />} />
44 <Show when={props.isReindexing} fallback="Reindex">Reindexing...</Show>
45 </button>
46 );
47}
48
49function SyncButton(props: { isSyncing: boolean; isReindexing: boolean; onSync: () => void }) {
50 return (
51 <button
52 type="button"
53 onClick={() => void props.onSync()}
54 disabled={props.isSyncing || props.isReindexing}
55 class="inline-flex items-center gap-2 rounded-xl border-0 bg-white/6 px-3 py-2 text-xs font-medium text-on-surface-variant transition hover:bg-white/10 hover:text-on-surface disabled:cursor-not-allowed disabled:opacity-50">
56 <LoadingIcon isLoading={props.isSyncing} class="text-base" fallback={<Icon kind="refresh" />} />
57 <Show when={props.isSyncing} fallback="Sync now">Syncing...</Show>
58 </button>
59 );
60}
61
62function SyncHeader(
63 props: {
64 hasAnyPosts: boolean;
65 icon: "db";
66 lastSync: string | null;
67 totalPosts: number;
68 tone: { className: string; label: string };
69 },
70) {
71 return (
72 <div class="flex items-center justify-between gap-3">
73 <div class="grid gap-1">
74 <div class="flex items-center gap-2">
75 <p class="m-0 text-sm font-medium text-on-surface">Sync Status</p>
76 <span
77 class={`rounded-full px-2.5 py-1 text-[0.68rem] font-medium uppercase tracking-[0.12em] ${props.tone.className}`}>
78 {props.tone.label}
79 </span>
80 </div>
81
82 <Show
83 when={props.hasAnyPosts}
84 fallback={
85 <p class="m-0 text-xs text-on-surface-variant">
86 Local search stays empty until likes or bookmarks are indexed.
87 </p>
88 }>
89 <PostCount totalPosts={props.totalPosts} lastSync={props.lastSync} />
90 </Show>
91 </div>
92
93 <span class="grid h-10 w-10 place-items-center rounded-2xl bg-primary/10 text-primary">
94 <Icon kind={props.icon} class="text-lg" />
95 </span>
96 </div>
97 );
98}
99
100function SyncActions(
101 props: { hasAnyPosts: boolean; isReindexing: boolean; isSyncing: boolean; onReindex: () => void; onSync: () => void },
102) {
103 return (
104 <div class="flex flex-wrap items-center gap-2">
105 <Show when={props.hasAnyPosts}>
106 <ReindexButton isSyncing={props.isSyncing} isReindexing={props.isReindexing} onReindex={props.onReindex} />
107 </Show>
108
109 <SyncButton isSyncing={props.isSyncing} isReindexing={props.isReindexing} onSync={props.onSync} />
110 </div>
111 );
112}
113
114type SyncStatusPanelProps = { did: string; onStatusChange?: (status: SyncStatus[]) => void };
115
116export function SyncStatusPanel(props: SyncStatusPanelProps) {
117 const [syncStatus, setSyncStatus] = createSignal<SyncStatus[]>([]);
118 const [isSyncing, setIsSyncing] = createSignal(false);
119 const [isReindexing, setIsReindexing] = createSignal(false);
120
121 async function loadSyncStatus() {
122 try {
123 const status = await SearchController.getSyncStatus(props.did);
124 setSyncStatus(status);
125 props.onStatusChange?.(status);
126 } catch (error) {
127 logger.error("failed to load sync status", { keyValues: { error: String(error) } });
128 }
129 }
130
131 async function handleSync() {
132 setIsSyncing(true);
133 try {
134 await SearchController.syncPosts(props.did, "like");
135 await SearchController.syncPosts(props.did, "bookmark");
136 await loadSyncStatus();
137 } catch (error) {
138 logger.error("sync failed", { keyValues: { error: String(error) } });
139 } finally {
140 setIsSyncing(false);
141 }
142 }
143
144 async function handleReindex() {
145 setIsReindexing(true);
146 try {
147 const count = await SearchController.reindexEmbeddings();
148 logger.info("reindex complete", { keyValues: { count: String(count) } });
149 await loadSyncStatus();
150 } catch (error) {
151 logger.error("reindex failed", { keyValues: { error: String(error) } });
152 } finally {
153 setIsReindexing(false);
154 }
155 }
156
157 onMount(() => {
158 void loadSyncStatus();
159
160 const interval = setInterval(() => {
161 void loadSyncStatus();
162 }, 60_000);
163
164 onCleanup(() => clearInterval(interval));
165 });
166
167 const totalPosts = createMemo(() => syncStatus().reduce((sum, status) => sum + (status.postCount ?? 0), 0));
168 const hasAnyPosts = createMemo(() => totalPosts() > 0);
169 const lastSync = createMemo(() => {
170 const timestamps = syncStatus().map((status) => status.lastSyncedAt).filter(Boolean) as string[];
171 if (timestamps.length === 0) {
172 return null;
173 }
174
175 const latest = timestamps.toSorted((left, right) => right.localeCompare(left))[0];
176 return formatRelativeTime(latest);
177 });
178 const statusTone = createMemo(() => {
179 if (isSyncing() || isReindexing()) {
180 return { className: "bg-primary/15 text-primary", label: isReindexing() ? "Reindexing" : "Syncing" };
181 }
182
183 if (hasAnyPosts()) {
184 return { className: "bg-emerald-400/15 text-emerald-300", label: "Ready" };
185 }
186
187 return { className: "bg-white/8 text-on-surface-variant", label: "Empty" };
188 });
189
190 return (
191 <section class="panel-surface grid gap-4 p-5">
192 <div class="grid gap-3">
193 <SyncHeader
194 hasAnyPosts={hasAnyPosts()}
195 icon="db"
196 lastSync={lastSync()}
197 totalPosts={totalPosts()}
198 tone={statusTone()} />
199 <SyncActions
200 hasAnyPosts={hasAnyPosts()}
201 isReindexing={isReindexing()}
202 isSyncing={isSyncing()}
203 onReindex={handleReindex}
204 onSync={handleSync} />
205 </div>
206
207 <Presence>
208 <Show when={isSyncing() || isReindexing()}>
209 <Motion.div
210 data-testid="sync-activity-bar"
211 class="h-1.5 overflow-hidden rounded-full bg-white/8"
212 initial={{ opacity: 0, height: 0 }}
213 animate={{ opacity: 1, height: "0.375rem" }}
214 exit={{ opacity: 0, height: 0 }}
215 transition={{ duration: 0.2 }}>
216 <Motion.div
217 class="h-full w-2/3 rounded-full bg-linear-to-r from-primary to-primary-dim"
218 animate={{ x: ["-40%", "120%"] }}
219 transition={{ duration: isReindexing() ? 1.8 : 1.1, repeat: Infinity, easing: "linear" }} />
220 </Motion.div>
221 </Show>
222 </Presence>
223
224 <div class="grid gap-3 rounded-3xl bg-black/20 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]">
225 <For each={syncStatus()}>
226 {(status) => (
227 <SourceStatusRow
228 count={status.postCount ?? 0}
229 isActive={isSyncing()}
230 source={status.source}
231 cursor={status.cursor} />
232 )}
233 </For>
234 </div>
235 </section>
236 );
237}