@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.
- package/drizzle.config.ts +7 -0
- package/next.config.ts +1 -0
- package/package.json +25 -4
- package/src/app/api/avatars/[id]/route.ts +122 -25
- package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
- package/src/app/api/openclaw/agents/route.ts +77 -41
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +214 -0
- package/src/app/api/openclaw/chat/route.ts +272 -0
- package/src/app/api/openclaw/chat/search/route.ts +149 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +45 -4
- package/src/app/api/openclaw/events/route.ts +31 -2
- package/src/app/api/openclaw/logs/route.ts +20 -5
- package/src/app/api/openclaw/restart/route.ts +12 -4
- package/src/app/api/openclaw/session/status/route.ts +42 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +305 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +89 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +147 -28
- package/src/app/settings/page.tsx +300 -0
- package/src/cli/onboarding.ts +202 -37
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +310 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +152 -0
- package/src/components/chat/message-list.tsx +508 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +139 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +81 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +188 -0
- package/src/lib/config.ts +36 -4
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/__tests__/queries.test.ts +318 -0
- package/src/lib/db/index.ts +642 -0
- package/src/lib/db/queries.ts +1017 -0
- package/src/lib/db/schema.ts +160 -0
- package/src/lib/device-identity.ts +303 -0
- package/src/lib/gateway-connection.ts +273 -36
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +775 -0
- package/src/lib/hooks/use-openclaw.ts +105 -70
- package/src/lib/hooks/use-search.ts +113 -0
- package/src/lib/hooks/use-session-stats.ts +57 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/types/chat.ts +186 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
- package/vitest.config.ts +13 -0
package/src/app/page.tsx
CHANGED
|
@@ -1,45 +1,161 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
</
|
|
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
|
|
179
|
+
variant={getStatusBadgeVariant(agentStatus, isConnected)}
|
|
64
180
|
size="sm"
|
|
65
181
|
>
|
|
66
|
-
{isConnected
|
|
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
|
|
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
|
|
149
|
-
<div className="
|
|
150
|
-
|
|
151
|
-
<div className="
|
|
152
|
-
|
|
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
|
|
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
|
+
}
|