@castlekit/castle 0.1.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/drizzle.config.ts +7 -0
  2. package/next.config.ts +1 -0
  3. package/package.json +25 -4
  4. package/src/app/api/avatars/[id]/route.ts +122 -25
  5. package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
  6. package/src/app/api/openclaw/agents/route.ts +77 -41
  7. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  8. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  9. package/src/app/api/openclaw/chat/channels/route.ts +214 -0
  10. package/src/app/api/openclaw/chat/route.ts +272 -0
  11. package/src/app/api/openclaw/chat/search/route.ts +149 -0
  12. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  13. package/src/app/api/openclaw/config/route.ts +45 -4
  14. package/src/app/api/openclaw/events/route.ts +31 -2
  15. package/src/app/api/openclaw/logs/route.ts +20 -5
  16. package/src/app/api/openclaw/restart/route.ts +12 -4
  17. package/src/app/api/openclaw/session/status/route.ts +42 -0
  18. package/src/app/api/settings/avatar/route.ts +190 -0
  19. package/src/app/api/settings/route.ts +88 -0
  20. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  21. package/src/app/chat/[channelId]/page.tsx +305 -0
  22. package/src/app/chat/layout.tsx +96 -0
  23. package/src/app/chat/page.tsx +52 -0
  24. package/src/app/globals.css +89 -2
  25. package/src/app/layout.tsx +7 -1
  26. package/src/app/page.tsx +147 -28
  27. package/src/app/settings/page.tsx +300 -0
  28. package/src/cli/onboarding.ts +202 -37
  29. package/src/components/chat/agent-mention-popup.tsx +89 -0
  30. package/src/components/chat/archived-channels.tsx +190 -0
  31. package/src/components/chat/channel-list.tsx +140 -0
  32. package/src/components/chat/chat-input.tsx +310 -0
  33. package/src/components/chat/create-channel-dialog.tsx +171 -0
  34. package/src/components/chat/markdown-content.tsx +205 -0
  35. package/src/components/chat/message-bubble.tsx +152 -0
  36. package/src/components/chat/message-list.tsx +508 -0
  37. package/src/components/chat/message-queue.tsx +68 -0
  38. package/src/components/chat/session-divider.tsx +61 -0
  39. package/src/components/chat/session-stats-panel.tsx +139 -0
  40. package/src/components/chat/storage-indicator.tsx +76 -0
  41. package/src/components/layout/sidebar.tsx +126 -45
  42. package/src/components/layout/user-menu.tsx +29 -4
  43. package/src/components/providers/presence-provider.tsx +8 -0
  44. package/src/components/providers/search-provider.tsx +81 -0
  45. package/src/components/search/search-dialog.tsx +269 -0
  46. package/src/components/ui/avatar.tsx +11 -9
  47. package/src/components/ui/dialog.tsx +10 -4
  48. package/src/components/ui/tooltip.tsx +25 -8
  49. package/src/components/ui/twemoji-text.tsx +37 -0
  50. package/src/lib/api-security.ts +188 -0
  51. package/src/lib/config.ts +36 -4
  52. package/src/lib/date-utils.ts +79 -0
  53. package/src/lib/db/__tests__/queries.test.ts +318 -0
  54. package/src/lib/db/index.ts +642 -0
  55. package/src/lib/db/queries.ts +1017 -0
  56. package/src/lib/db/schema.ts +160 -0
  57. package/src/lib/device-identity.ts +303 -0
  58. package/src/lib/gateway-connection.ts +273 -36
  59. package/src/lib/hooks/use-agent-status.ts +251 -0
  60. package/src/lib/hooks/use-chat.ts +775 -0
  61. package/src/lib/hooks/use-openclaw.ts +105 -70
  62. package/src/lib/hooks/use-search.ts +113 -0
  63. package/src/lib/hooks/use-session-stats.ts +57 -0
  64. package/src/lib/hooks/use-user-settings.ts +46 -0
  65. package/src/lib/types/chat.ts +186 -0
  66. package/src/lib/types/search.ts +60 -0
  67. package/src/middleware.ts +52 -0
  68. package/vitest.config.ts +13 -0
package/src/app/page.tsx CHANGED
@@ -1,45 +1,161 @@
1
1
  "use client";
2
2
 
3
- import { Bot, Wifi, WifiOff, Crown, RefreshCw, Loader2, AlertCircle } from "lucide-react";
3
+ import { useRef, useState, useCallback } from "react";
4
+ import { Bot, Wifi, WifiOff, Crown, RefreshCw, Loader2, AlertCircle, Camera } from "lucide-react";
4
5
  import { Sidebar } from "@/components/layout/sidebar";
5
- import { UserMenu } from "@/components/layout/user-menu";
6
+
6
7
  import { PageHeader } from "@/components/layout/page-header";
7
8
  import { Card, CardContent } from "@/components/ui/card";
8
9
  import { Badge } from "@/components/ui/badge";
9
10
  import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
10
11
  import { cn } from "@/lib/utils";
11
12
  import { useOpenClaw, type OpenClawAgent } from "@/lib/hooks/use-openclaw";
13
+ import { useAgentStatus, type AgentStatus } from "@/lib/hooks/use-agent-status";
12
14
 
13
15
  function getInitials(name: string) {
14
16
  return name.slice(0, 2).toUpperCase();
15
17
  }
16
18
 
17
- function AgentCard({ agent, isPrimary, isConnected }: { agent: OpenClawAgent; isPrimary: boolean; isConnected: boolean }) {
19
+ function getStatusLabel(status: AgentStatus, isConnected: boolean): string {
20
+ if (!isConnected) return "Offline";
21
+ switch (status) {
22
+ case "thinking": return "Thinking";
23
+ case "active": return "Active";
24
+ default: return "Idle";
25
+ }
26
+ }
27
+
28
+ function getStatusBadgeVariant(status: AgentStatus, isConnected: boolean): "success" | "warning" | "outline" {
29
+ if (!isConnected) return "outline";
30
+ switch (status) {
31
+ case "thinking": return "warning";
32
+ case "active": return "success";
33
+ default: return "outline";
34
+ }
35
+ }
36
+
37
+ function getAvatarStatus(status: AgentStatus, isConnected: boolean): "online" | "offline" | "busy" | "away" {
38
+ if (!isConnected) return "offline";
39
+ switch (status) {
40
+ case "thinking": return "away";
41
+ case "active": return "online";
42
+ default: return "offline";
43
+ }
44
+ }
45
+
46
+ function AgentCard({
47
+ agent,
48
+ isPrimary,
49
+ isConnected,
50
+ onAvatarUpdated,
51
+ }: {
52
+ agent: OpenClawAgent;
53
+ isPrimary: boolean;
54
+ isConnected: boolean;
55
+ onAvatarUpdated: () => void;
56
+ }) {
57
+ const fileInputRef = useRef<HTMLInputElement>(null);
58
+ const [uploading, setUploading] = useState(false);
59
+ const { getStatus } = useAgentStatus();
60
+ const agentStatus = getStatus(agent.id);
61
+
62
+ const handleAvatarClick = useCallback(() => {
63
+ if (!isConnected) return;
64
+ fileInputRef.current?.click();
65
+ }, [isConnected]);
66
+
67
+ const handleFileChange = useCallback(
68
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
69
+ const file = e.target.files?.[0];
70
+ if (!file) return;
71
+
72
+ // Reset input so the same file can be selected again
73
+ e.target.value = "";
74
+
75
+ // Client-side validation
76
+ if (file.size > 5 * 1024 * 1024) {
77
+ alert("Image too large (max 5MB)");
78
+ return;
79
+ }
80
+
81
+ setUploading(true);
82
+ try {
83
+ const formData = new FormData();
84
+ formData.append("avatar", file);
85
+
86
+ const resp = await fetch(`/api/openclaw/agents/${agent.id}/avatar`, {
87
+ method: "POST",
88
+ body: formData,
89
+ });
90
+
91
+ const result = await resp.json();
92
+ if (!resp.ok) {
93
+ alert(result.error || "Failed to update avatar");
94
+ return;
95
+ }
96
+
97
+ // Gateway hot-reloads — just refresh agents to pick up new avatar
98
+ onAvatarUpdated();
99
+ } catch {
100
+ alert("Failed to upload avatar");
101
+ } finally {
102
+ setUploading(false);
103
+ }
104
+ },
105
+ [agent.id, onAvatarUpdated]
106
+ );
107
+
18
108
  return (
19
109
  <Card
20
110
  variant="bordered"
21
111
  className={cn(
22
- "p-4 transition-colors",
112
+ "py-4 pl-5 pr-4 transition-colors min-h-[80px] flex items-center",
23
113
  isConnected
24
114
  ? "hover:border-border-hover"
25
115
  : "opacity-60"
26
116
  )}
27
117
  >
28
- <div className="flex items-center justify-between">
118
+ <div className="flex items-center justify-between flex-1">
29
119
  <div className="flex items-center gap-3">
30
- <Avatar size="md" status={isConnected ? "online" : "offline"}>
31
- {agent.avatar ? (
32
- <AvatarImage
33
- src={agent.avatar}
34
- alt={agent.name}
35
- className={cn(!isConnected && "grayscale")}
36
- />
37
- ) : (
38
- <AvatarFallback>
39
- {agent.emoji || getInitials(agent.name)}
40
- </AvatarFallback>
120
+ {/* Clickable avatar with upload overlay */}
121
+ <button
122
+ type="button"
123
+ onClick={handleAvatarClick}
124
+ disabled={!isConnected || uploading}
125
+ className="relative group rounded-[4px] focus:outline-none focus-visible:ring-2 focus-visible:ring-accent leading-[0]"
126
+ title={isConnected ? "Click to change avatar" : undefined}
127
+ >
128
+ <Avatar size="md" status={getAvatarStatus(agentStatus, isConnected)} statusPulse={agentStatus === "thinking"}>
129
+ {agent.avatar ? (
130
+ <AvatarImage
131
+ src={agent.avatar}
132
+ alt={agent.name}
133
+ className={cn(!isConnected && "grayscale")}
134
+ />
135
+ ) : (
136
+ <AvatarFallback>
137
+ {agent.emoji || getInitials(agent.name)}
138
+ </AvatarFallback>
139
+ )}
140
+ </Avatar>
141
+ {isConnected && !uploading && (
142
+ <div className="absolute inset-0 rounded-[4px] bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
143
+ <Camera className="h-4 w-4 text-white" />
144
+ </div>
145
+ )}
146
+ {uploading && (
147
+ <div className="absolute inset-0 rounded-[4px] bg-black/50 flex items-center justify-center">
148
+ <Loader2 className="h-4 w-4 text-white animate-spin" />
149
+ </div>
41
150
  )}
42
- </Avatar>
151
+ </button>
152
+ <input
153
+ ref={fileInputRef}
154
+ type="file"
155
+ accept="image/png,image/jpeg,image/webp,image/gif"
156
+ className="hidden"
157
+ onChange={handleFileChange}
158
+ />
43
159
  <div>
44
160
  <div className="flex items-center gap-2">
45
161
  <p className="text-sm font-medium text-foreground">
@@ -60,10 +176,10 @@ function AgentCard({ agent, isPrimary, isConnected }: { agent: OpenClawAgent; is
60
176
  </div>
61
177
  </div>
62
178
  <Badge
63
- variant={isConnected ? "success" : "outline"}
179
+ variant={getStatusBadgeVariant(agentStatus, isConnected)}
64
180
  size="sm"
65
181
  >
66
- {isConnected ? "Active" : "Offline"}
182
+ {getStatusLabel(agentStatus, isConnected)}
67
183
  </Badge>
68
184
  </div>
69
185
  </Card>
@@ -92,7 +208,7 @@ function ConnectionCard({
92
208
  if (!isConfigured) return "Run 'castle setup' to configure";
93
209
  if (isConnected) {
94
210
  const parts = ["Connected"];
95
- if (serverVersion) parts[0] = `Connected to OpenClaw v${serverVersion}`;
211
+ if (serverVersion) parts[0] = `Connected to OpenClaw ${serverVersion}`;
96
212
  if (latency) parts.push(`${latency}ms`);
97
213
  return parts.join(" · ");
98
214
  }
@@ -144,13 +260,16 @@ function AgentsSkeleton() {
144
260
  return (
145
261
  <div className="grid gap-3">
146
262
  {[1, 2, 3].map((i) => (
147
- <Card key={i} variant="bordered" className="p-4">
148
- <div className="flex items-center gap-3">
149
- <div className="h-10 w-10 rounded-full bg-surface-hover animate-pulse" />
150
- <div className="space-y-2">
151
- <div className="h-4 w-24 bg-surface-hover rounded animate-pulse" />
152
- <div className="h-3 w-32 bg-surface-hover rounded animate-pulse" />
263
+ <Card key={i} variant="bordered" className="p-4 min-h-[80px] flex items-center">
264
+ <div className="flex items-center justify-between flex-1">
265
+ <div className="flex items-center gap-3">
266
+ <div className="skeleton h-10 w-10 rounded-full" />
267
+ <div className="space-y-2">
268
+ <div className="skeleton h-4 w-24 rounded" />
269
+ <div className="skeleton h-3 w-32 rounded" />
270
+ </div>
153
271
  </div>
272
+ <div className="skeleton h-5 w-14 rounded-full" />
154
273
  </div>
155
274
  </Card>
156
275
  ))}
@@ -208,7 +327,6 @@ export default function HomePage() {
208
327
  return (
209
328
  <div className="min-h-screen bg-background">
210
329
  <Sidebar variant="solid" />
211
- <UserMenu className="fixed top-5 right-6 z-50" variant="solid" />
212
330
 
213
331
  <main className="min-h-screen ml-[80px]">
214
332
  <div className="p-8 max-w-4xl">
@@ -245,7 +363,7 @@ export default function HomePage() {
245
363
  )}
246
364
  </div>
247
365
 
248
- {agentsLoading && isLoading ? (
366
+ {agentsLoading || (isLoading && agents.length === 0) ? (
249
367
  <AgentsSkeleton />
250
368
  ) : agents.length > 0 ? (
251
369
  <div className="grid gap-3">
@@ -255,6 +373,7 @@ export default function HomePage() {
255
373
  agent={agent}
256
374
  isPrimary={idx === 0}
257
375
  isConnected={isConnected}
376
+ onAvatarUpdated={refresh}
258
377
  />
259
378
  ))}
260
379
  </div>
@@ -0,0 +1,300 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useRef } from "react";
4
+ import { Loader2, Check, ArrowLeft, Camera, Trash2, User } from "lucide-react";
5
+ import { useRouter } from "next/navigation";
6
+ import { Sidebar } from "@/components/layout/sidebar";
7
+
8
+ import { Button } from "@/components/ui/button";
9
+ import { Input } from "@/components/ui/input";
10
+ import { useUserSettings } from "@/lib/hooks/use-user-settings";
11
+ import { cn } from "@/lib/utils";
12
+
13
+ export default function SettingsPage() {
14
+ const router = useRouter();
15
+ const { displayName: savedName, avatarUrl: sharedAvatarUrl, tooltips: savedTooltips, isLoading: settingsLoading, refresh } = useUserSettings();
16
+ const [displayName, setDisplayName] = useState("");
17
+ const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
18
+ const [initialized, setInitialized] = useState(false);
19
+ const [saving, setSaving] = useState(false);
20
+ const [saved, setSaved] = useState(false);
21
+ const [uploadingAvatar, setUploadingAvatar] = useState(false);
22
+ const [avatarSaved, setAvatarSaved] = useState(false);
23
+ const [avatarError, setAvatarError] = useState("");
24
+ const [tooltipsEnabled, setTooltipsEnabled] = useState<boolean | null>(null);
25
+ const fileInputRef = useRef<HTMLInputElement>(null);
26
+
27
+ // Sync from SWR on initial load
28
+ useEffect(() => {
29
+ if (!settingsLoading && !initialized) {
30
+ setDisplayName(savedName);
31
+ setAvatarUrl(sharedAvatarUrl);
32
+ setTooltipsEnabled(savedTooltips);
33
+ setInitialized(true);
34
+ }
35
+ }, [settingsLoading, savedName, sharedAvatarUrl, savedTooltips, initialized]);
36
+
37
+ const loading = settingsLoading && !initialized;
38
+
39
+ const handleSave = async () => {
40
+ setSaving(true);
41
+ setSaved(false);
42
+ try {
43
+ const res = await fetch("/api/settings", {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({ displayName }),
47
+ });
48
+ if (res.ok) {
49
+ setSaved(true);
50
+ refresh();
51
+ setTimeout(() => setSaved(false), 2000);
52
+ }
53
+ } catch {
54
+ // silent
55
+ } finally {
56
+ setSaving(false);
57
+ }
58
+ };
59
+
60
+ const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
61
+ const file = e.target.files?.[0];
62
+ if (!file) return;
63
+
64
+ setAvatarError("");
65
+ setAvatarSaved(false);
66
+ setUploadingAvatar(true);
67
+
68
+ try {
69
+ const formData = new FormData();
70
+ formData.append("avatar", file);
71
+
72
+ const res = await fetch("/api/settings/avatar", {
73
+ method: "POST",
74
+ body: formData,
75
+ });
76
+
77
+ const data = await res.json();
78
+
79
+ if (res.ok) {
80
+ setAvatarUrl(data.avatar);
81
+ setAvatarSaved(true);
82
+ refresh();
83
+ setTimeout(() => setAvatarSaved(false), 2000);
84
+ } else {
85
+ setAvatarError(data.error || "Upload failed");
86
+ }
87
+ } catch {
88
+ setAvatarError("Upload failed");
89
+ } finally {
90
+ setUploadingAvatar(false);
91
+ // Reset file input so the same file can be re-selected
92
+ if (fileInputRef.current) fileInputRef.current.value = "";
93
+ }
94
+ };
95
+
96
+ const handleAvatarRemove = async () => {
97
+ try {
98
+ const res = await fetch("/api/settings/avatar", { method: "DELETE" });
99
+ if (res.ok) {
100
+ setAvatarUrl(null);
101
+ refresh();
102
+ }
103
+ } catch {
104
+ // silent
105
+ }
106
+ };
107
+
108
+ return (
109
+ <div className="min-h-screen bg-background">
110
+ <Sidebar variant="solid" />
111
+
112
+ <div className="ml-[80px] p-8 max-w-2xl">
113
+ <div className="mb-8">
114
+ <button
115
+ onClick={() => router.push("/")}
116
+ className="inline-flex items-center gap-1.5 text-sm text-foreground-secondary hover:text-foreground transition-colors mb-4 cursor-pointer"
117
+ >
118
+ <ArrowLeft className="h-4 w-4" />
119
+ Back
120
+ </button>
121
+ <h1 className="text-2xl font-semibold text-foreground">Settings</h1>
122
+ <p className="text-sm text-foreground-secondary mt-1">
123
+ Configure your Castle preferences
124
+ </p>
125
+ </div>
126
+
127
+ {loading ? (
128
+ <div className="flex items-center justify-center py-12">
129
+ <Loader2 className="h-6 w-6 animate-spin text-foreground-secondary" />
130
+ </div>
131
+ ) : (
132
+ <div className="space-y-8">
133
+ {/* Profile section */}
134
+ <div className="panel p-6">
135
+ <h2 className="text-sm font-semibold text-foreground mb-4">
136
+ Profile
137
+ </h2>
138
+ <div className="space-y-6">
139
+ {/* Avatar */}
140
+ <div>
141
+ <label className="block text-sm font-medium text-foreground-secondary mb-3">
142
+ Avatar
143
+ </label>
144
+ <div className="flex items-center gap-4">
145
+ {/* Avatar preview */}
146
+ <div className="relative group">
147
+ <div className="w-20 h-20 rounded-[4px] overflow-hidden bg-surface-hover border-2 border-border flex items-center justify-center">
148
+ {uploadingAvatar ? (
149
+ <Loader2 className="h-6 w-6 animate-spin text-foreground-secondary" />
150
+ ) : avatarUrl ? (
151
+ <img
152
+ src={avatarUrl}
153
+ alt="Your avatar"
154
+ className="w-full h-full object-cover"
155
+ />
156
+ ) : (
157
+ <User className="h-8 w-8 text-foreground-secondary" />
158
+ )}
159
+ </div>
160
+ {/* Hover overlay */}
161
+ <button
162
+ onClick={() => fileInputRef.current?.click()}
163
+ disabled={uploadingAvatar}
164
+ className="absolute inset-0 w-20 h-20 rounded-[4px] bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
165
+ >
166
+ <Camera className="h-5 w-5 text-white" />
167
+ </button>
168
+ </div>
169
+
170
+ <div className="flex flex-col gap-2">
171
+ <div className="flex items-center gap-2">
172
+ <Button
173
+ variant="outline"
174
+ size="sm"
175
+ onClick={() => fileInputRef.current?.click()}
176
+ disabled={uploadingAvatar}
177
+ >
178
+ {avatarUrl ? "Change" : "Upload"}
179
+ </Button>
180
+ {avatarUrl && (
181
+ <Button
182
+ variant="ghost"
183
+ size="sm"
184
+ onClick={handleAvatarRemove}
185
+ className="text-foreground-secondary hover:text-red-400"
186
+ >
187
+ <Trash2 className="h-3.5 w-3.5" />
188
+ </Button>
189
+ )}
190
+ </div>
191
+ <p className="text-xs text-foreground-secondary">
192
+ PNG, JPEG, WebP, or GIF. Max 5MB. Resized to 256x256.
193
+ </p>
194
+ {avatarSaved && (
195
+ <p className="text-xs text-green-400 flex items-center gap-1">
196
+ <Check className="h-3 w-3" />
197
+ Avatar saved
198
+ </p>
199
+ )}
200
+ {avatarError && (
201
+ <p className="text-xs text-red-400">{avatarError}</p>
202
+ )}
203
+ </div>
204
+ </div>
205
+ <input
206
+ ref={fileInputRef}
207
+ type="file"
208
+ accept="image/png,image/jpeg,image/webp,image/gif"
209
+ onChange={handleAvatarUpload}
210
+ className="hidden"
211
+ />
212
+ </div>
213
+
214
+ {/* Display Name */}
215
+ <div>
216
+ <label className="block text-sm font-medium text-foreground-secondary mb-1.5">
217
+ Display Name
218
+ </label>
219
+ <div className="flex items-center gap-3">
220
+ <Input
221
+ value={displayName}
222
+ onChange={(e) => setDisplayName(e.target.value)}
223
+ placeholder="Your name"
224
+ className="max-w-xs"
225
+ />
226
+ <Button
227
+ onClick={handleSave}
228
+ disabled={saving}
229
+ size="sm"
230
+ >
231
+ {saving ? (
232
+ <Loader2 className="h-4 w-4 animate-spin" />
233
+ ) : saved ? (
234
+ <>
235
+ <Check className="h-4 w-4 mr-1.5" />
236
+ Saved
237
+ </>
238
+ ) : (
239
+ "Save"
240
+ )}
241
+ </Button>
242
+ </div>
243
+ <p className="text-xs text-foreground-secondary mt-1.5">
244
+ Shown in chat channel headers and message attribution.
245
+ </p>
246
+ </div>
247
+ </div>
248
+ </div>
249
+
250
+ {/* Interface section */}
251
+ <div className="panel p-6">
252
+ <h2 className="text-sm font-semibold text-foreground mb-4">
253
+ Interface
254
+ </h2>
255
+ <div className="flex items-center justify-between">
256
+ <div>
257
+ <p className="text-sm font-medium text-foreground">
258
+ Menu tooltips
259
+ </p>
260
+ <p className="text-xs text-foreground-secondary mt-0.5">
261
+ Show tooltips when hovering sidebar menu icons
262
+ </p>
263
+ </div>
264
+ <button
265
+ onClick={async () => {
266
+ const newValue = !tooltipsEnabled;
267
+ setTooltipsEnabled(newValue);
268
+ try {
269
+ await fetch("/api/settings", {
270
+ method: "POST",
271
+ headers: { "Content-Type": "application/json" },
272
+ body: JSON.stringify({ tooltips: String(newValue) }),
273
+ });
274
+ refresh();
275
+ } catch {
276
+ setTooltipsEnabled(!newValue); // revert on error
277
+ }
278
+ }}
279
+ className={cn(
280
+ "relative inline-flex h-6 w-11 items-center rounded-full cursor-pointer",
281
+ initialized && "transition-colors",
282
+ tooltipsEnabled ? "bg-accent" : "bg-foreground-muted/30"
283
+ )}
284
+ >
285
+ <span
286
+ className={cn(
287
+ "inline-block h-4 w-4 rounded-full bg-white",
288
+ initialized && "transition-transform",
289
+ tooltipsEnabled ? "translate-x-6" : "translate-x-1"
290
+ )}
291
+ />
292
+ </button>
293
+ </div>
294
+ </div>
295
+ </div>
296
+ )}
297
+ </div>
298
+ </div>
299
+ );
300
+ }