@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
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Search, Clock, X } from "lucide-react";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { formatDateTime } from "@/lib/date-utils";
|
|
8
|
+
import { useSearch } from "@/lib/hooks/use-search";
|
|
9
|
+
import type {
|
|
10
|
+
SearchResult,
|
|
11
|
+
SearchResultType,
|
|
12
|
+
MessageSearchResult,
|
|
13
|
+
} from "@/lib/types/search";
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Result renderers — one per content type (pluggable registry)
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
function MessageResultRow({ result }: { result: MessageSearchResult }) {
|
|
20
|
+
const timeStr = formatDateTime(result.timestamp);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="flex items-start gap-3 min-w-0">
|
|
24
|
+
<div className="min-w-0 flex-1">
|
|
25
|
+
<div className="flex items-center gap-2 text-xs text-foreground-secondary mb-0.5">
|
|
26
|
+
<span className="px-1.5 py-0.5 rounded bg-accent/10 text-accent font-medium text-[11px]">
|
|
27
|
+
{result.title}
|
|
28
|
+
</span>
|
|
29
|
+
{result.archived && (
|
|
30
|
+
<span className="text-[11px] text-foreground-secondary/50 italic">archived</span>
|
|
31
|
+
)}
|
|
32
|
+
<span className="font-medium">{result.subtitle}</span>
|
|
33
|
+
<span className="text-foreground-secondary/60">{timeStr}</span>
|
|
34
|
+
</div>
|
|
35
|
+
<p className="text-sm text-foreground truncate">{result.snippet}</p>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Registry: map each SearchResultType to its renderer
|
|
42
|
+
const resultRenderers: Record<
|
|
43
|
+
SearchResultType,
|
|
44
|
+
((result: SearchResult) => ReactNode) | null
|
|
45
|
+
> = {
|
|
46
|
+
message: (r) => <MessageResultRow result={r as MessageSearchResult} />,
|
|
47
|
+
task: null, // Future
|
|
48
|
+
note: null, // Future
|
|
49
|
+
project: null, // Future
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// SearchDialog
|
|
54
|
+
// ============================================================================
|
|
55
|
+
|
|
56
|
+
interface SearchDialogProps {
|
|
57
|
+
open: boolean;
|
|
58
|
+
onClose: () => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
|
62
|
+
const router = useRouter();
|
|
63
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
64
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
65
|
+
const { query, setQuery, results, isSearching, recentSearches, clearRecentSearches } =
|
|
66
|
+
useSearch();
|
|
67
|
+
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
68
|
+
|
|
69
|
+
// Focus input on open
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (open) {
|
|
72
|
+
// Small delay to ensure the DOM is ready
|
|
73
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
74
|
+
setSelectedIndex(-1);
|
|
75
|
+
}
|
|
76
|
+
}, [open]);
|
|
77
|
+
|
|
78
|
+
// Reset selection when results change
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
setSelectedIndex(-1);
|
|
81
|
+
}, [results]);
|
|
82
|
+
|
|
83
|
+
// Determine what items are shown (results or recent searches)
|
|
84
|
+
const showRecent = !query.trim() && recentSearches.length > 0;
|
|
85
|
+
const itemCount = showRecent ? recentSearches.length : results.length;
|
|
86
|
+
|
|
87
|
+
// Keyboard navigation
|
|
88
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
89
|
+
if (e.key === "Escape") {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
onClose();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (e.key === "ArrowDown") {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
setSelectedIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (e.key === "ArrowUp") {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (e.key === "Enter" && selectedIndex >= 0) {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
if (showRecent) {
|
|
110
|
+
// Fill input with the recent search
|
|
111
|
+
setQuery(recentSearches[selectedIndex]);
|
|
112
|
+
} else if (results[selectedIndex]) {
|
|
113
|
+
navigateToResult(results[selectedIndex]);
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Scroll selected item into view
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (selectedIndex < 0 || !listRef.current) return;
|
|
122
|
+
const items = listRef.current.querySelectorAll("[data-search-item]");
|
|
123
|
+
items[selectedIndex]?.scrollIntoView({ block: "nearest" });
|
|
124
|
+
}, [selectedIndex]);
|
|
125
|
+
|
|
126
|
+
const navigateToResult = (result: SearchResult) => {
|
|
127
|
+
router.push(result.href);
|
|
128
|
+
// Close after navigation is dispatched — ensures router.push isn't
|
|
129
|
+
// interrupted by the dialog unmounting
|
|
130
|
+
setTimeout(onClose, 0);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
if (!open) return null;
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div
|
|
137
|
+
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]"
|
|
138
|
+
onClick={onClose}
|
|
139
|
+
>
|
|
140
|
+
{/* Backdrop */}
|
|
141
|
+
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
|
142
|
+
|
|
143
|
+
{/* Dialog */}
|
|
144
|
+
<div
|
|
145
|
+
className="relative w-full max-w-[560px] rounded-[var(--radius-md)] bg-surface border border-border shadow-2xl overflow-hidden"
|
|
146
|
+
onClick={(e) => e.stopPropagation()}
|
|
147
|
+
>
|
|
148
|
+
{/* Search input */}
|
|
149
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
|
|
150
|
+
<Search className="h-5 w-5 text-foreground-secondary shrink-0" />
|
|
151
|
+
<input
|
|
152
|
+
ref={inputRef}
|
|
153
|
+
type="text"
|
|
154
|
+
value={query}
|
|
155
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
156
|
+
onKeyDown={handleKeyDown}
|
|
157
|
+
placeholder="Search across all channels..."
|
|
158
|
+
className="flex-1 bg-transparent text-[15px] focus:outline-none placeholder:text-foreground-secondary/50"
|
|
159
|
+
/>
|
|
160
|
+
<kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[11px] font-medium text-foreground-secondary/60 bg-surface-hover rounded border border-border/50">
|
|
161
|
+
ESC
|
|
162
|
+
</kbd>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
{/* Results / Recent searches */}
|
|
166
|
+
<div ref={listRef} className="max-h-[400px] overflow-y-auto">
|
|
167
|
+
{/* Recent searches (before typing) */}
|
|
168
|
+
{showRecent && (
|
|
169
|
+
<div>
|
|
170
|
+
<div className="flex items-center justify-between px-4 pt-3 pb-1.5">
|
|
171
|
+
<span className="text-xs font-medium text-foreground-secondary/60 uppercase tracking-wider">
|
|
172
|
+
Recent searches
|
|
173
|
+
</span>
|
|
174
|
+
<button
|
|
175
|
+
onClick={clearRecentSearches}
|
|
176
|
+
className="text-xs text-foreground-secondary/60 hover:text-foreground-secondary transition-colors cursor-pointer"
|
|
177
|
+
>
|
|
178
|
+
Clear
|
|
179
|
+
</button>
|
|
180
|
+
</div>
|
|
181
|
+
{recentSearches.map((recent, i) => (
|
|
182
|
+
<button
|
|
183
|
+
key={recent}
|
|
184
|
+
data-search-item
|
|
185
|
+
onClick={() => setQuery(recent)}
|
|
186
|
+
className={cn(
|
|
187
|
+
"flex items-center gap-3 w-full px-4 py-2.5 text-sm text-left transition-colors cursor-pointer",
|
|
188
|
+
selectedIndex === i
|
|
189
|
+
? "bg-accent/10 text-accent"
|
|
190
|
+
: "text-foreground hover:bg-surface-hover"
|
|
191
|
+
)}
|
|
192
|
+
>
|
|
193
|
+
<Clock className="h-4 w-4 text-foreground-secondary/60 shrink-0" />
|
|
194
|
+
<span className="truncate">{recent}</span>
|
|
195
|
+
</button>
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
{/* Empty state — no query, no recent */}
|
|
201
|
+
{!query.trim() && recentSearches.length === 0 && (
|
|
202
|
+
<div className="px-4 py-8 text-center text-sm text-foreground-secondary/60">
|
|
203
|
+
Search across all channels
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{/* Loading skeleton */}
|
|
208
|
+
{query.trim() && isSearching && results.length === 0 && (
|
|
209
|
+
<div className="px-2">
|
|
210
|
+
{[1, 2, 3, 4, 5].map((i) => (
|
|
211
|
+
<div key={i} className="px-2 py-3 border-b border-border/20 last:border-b-0">
|
|
212
|
+
<div className="flex items-center gap-2 mb-1.5">
|
|
213
|
+
<div className="skeleton h-4 w-16 rounded" />
|
|
214
|
+
<div className="skeleton h-3 w-12 rounded" />
|
|
215
|
+
<div className="skeleton h-3 w-10 rounded" />
|
|
216
|
+
</div>
|
|
217
|
+
<div className="skeleton h-3.5 rounded" style={{ width: `${55 + i * 8}%` }} />
|
|
218
|
+
</div>
|
|
219
|
+
))}
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
{/* Results */}
|
|
224
|
+
{query.trim() && !isSearching && results.length === 0 && (
|
|
225
|
+
<div className="px-4 py-8 text-center text-sm text-foreground-secondary">
|
|
226
|
+
No results found
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
{query.trim() &&
|
|
231
|
+
results.map((result, i) => {
|
|
232
|
+
const renderer = resultRenderers[result.type];
|
|
233
|
+
if (!renderer) return null;
|
|
234
|
+
return (
|
|
235
|
+
<button
|
|
236
|
+
key={result.id}
|
|
237
|
+
data-search-item
|
|
238
|
+
onClick={() => navigateToResult(result)}
|
|
239
|
+
className={cn(
|
|
240
|
+
"w-full px-4 py-3 text-left transition-colors cursor-pointer border-b border-border/20 last:border-b-0",
|
|
241
|
+
selectedIndex === i
|
|
242
|
+
? "bg-accent/10"
|
|
243
|
+
: "hover:bg-surface-hover"
|
|
244
|
+
)}
|
|
245
|
+
>
|
|
246
|
+
{renderer(result)}
|
|
247
|
+
</button>
|
|
248
|
+
);
|
|
249
|
+
})}
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
{/* Footer hint */}
|
|
253
|
+
{query.trim() && results.length > 0 && (
|
|
254
|
+
<div className="px-4 py-2 border-t border-border/50 text-[11px] text-foreground-secondary/50 flex items-center gap-3">
|
|
255
|
+
<span>
|
|
256
|
+
<kbd className="px-1 py-0.5 rounded bg-surface-hover border border-border/50 text-[10px]">↑↓</kbd> Navigate
|
|
257
|
+
</span>
|
|
258
|
+
<span>
|
|
259
|
+
<kbd className="px-1 py-0.5 rounded bg-surface-hover border border-border/50 text-[10px]">↵</kbd> Open
|
|
260
|
+
</span>
|
|
261
|
+
<span>
|
|
262
|
+
<kbd className="px-1 py-0.5 rounded bg-surface-hover border border-border/50 text-[10px]">esc</kbd> Close
|
|
263
|
+
</span>
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
@@ -4,17 +4,18 @@ import { cn } from "@/lib/utils";
|
|
|
4
4
|
export interface AvatarProps extends HTMLAttributes<HTMLDivElement> {
|
|
5
5
|
size?: "sm" | "md" | "lg";
|
|
6
6
|
status?: "online" | "offline" | "busy" | "away";
|
|
7
|
+
statusPulse?: boolean;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
|
|
10
|
-
({ className, size = "md", status, children, ...props }, ref) => {
|
|
11
|
+
({ className, size = "md", status, statusPulse, children, ...props }, ref) => {
|
|
11
12
|
return (
|
|
12
|
-
<div className="relative inline-block">
|
|
13
|
+
<div className="relative inline-block shrink-0">
|
|
13
14
|
<div
|
|
14
15
|
className={cn(
|
|
15
|
-
"relative flex shrink-0 overflow-hidden rounded-[
|
|
16
|
+
"relative flex shrink-0 overflow-hidden rounded-[4px] bg-surface border border-border",
|
|
16
17
|
{
|
|
17
|
-
"h-
|
|
18
|
+
"h-9 w-9": size === "sm",
|
|
18
19
|
"h-10 w-10": size === "md",
|
|
19
20
|
"h-12 w-12": size === "lg",
|
|
20
21
|
},
|
|
@@ -28,18 +29,19 @@ const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
|
|
|
28
29
|
{status && (
|
|
29
30
|
<span
|
|
30
31
|
className={cn(
|
|
31
|
-
"absolute
|
|
32
|
+
"absolute block rounded-full ring-2 ring-background",
|
|
32
33
|
{
|
|
33
|
-
"h-2.5 w-2.5": size === "sm",
|
|
34
|
-
"h-3 w-3": size === "md",
|
|
35
|
-
"h-3.5 w-3.5": size === "lg",
|
|
34
|
+
"h-2.5 w-2.5 -bottom-0.5 -right-0.5": size === "sm",
|
|
35
|
+
"h-3 w-3 -bottom-0.5 -right-0.5": size === "md",
|
|
36
|
+
"h-3.5 w-3.5 -bottom-0.5 -right-0.5": size === "lg",
|
|
36
37
|
},
|
|
37
38
|
{
|
|
38
39
|
"bg-success": status === "online",
|
|
39
40
|
"bg-foreground-muted": status === "offline",
|
|
40
41
|
"bg-error": status === "busy",
|
|
41
42
|
"bg-warning": status === "away",
|
|
42
|
-
}
|
|
43
|
+
},
|
|
44
|
+
statusPulse && "animate-pulse"
|
|
43
45
|
)}
|
|
44
46
|
/>
|
|
45
47
|
)}
|
|
@@ -7,17 +7,23 @@ import { cn } from "@/lib/utils";
|
|
|
7
7
|
export interface DialogProps extends HTMLAttributes<HTMLDivElement> {
|
|
8
8
|
open?: boolean;
|
|
9
9
|
onClose?: () => void;
|
|
10
|
+
onOpenChange?: (open: boolean) => void;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
const Dialog = forwardRef<HTMLDivElement, DialogProps>(
|
|
13
|
-
({ className, open = false, onClose, children, ...props }, ref) => {
|
|
14
|
+
({ className, open = false, onClose, onOpenChange, children, ...props }, ref) => {
|
|
14
15
|
if (!open) return null;
|
|
15
16
|
|
|
17
|
+
const handleClose = () => {
|
|
18
|
+
onClose?.();
|
|
19
|
+
onOpenChange?.(false);
|
|
20
|
+
};
|
|
21
|
+
|
|
16
22
|
return (
|
|
17
23
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
18
24
|
<div
|
|
19
25
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
|
20
|
-
onClick={
|
|
26
|
+
onClick={handleClose}
|
|
21
27
|
/>
|
|
22
28
|
<div
|
|
23
29
|
className={cn(
|
|
@@ -27,9 +33,9 @@ const Dialog = forwardRef<HTMLDivElement, DialogProps>(
|
|
|
27
33
|
ref={ref}
|
|
28
34
|
{...props}
|
|
29
35
|
>
|
|
30
|
-
{onClose && (
|
|
36
|
+
{(onClose || onOpenChange) && (
|
|
31
37
|
<button
|
|
32
|
-
onClick={
|
|
38
|
+
onClick={handleClose}
|
|
33
39
|
className="absolute right-4 top-4 p-1 rounded-[var(--radius-sm)] text-foreground-muted hover:text-foreground hover:bg-surface-hover transition-colors"
|
|
34
40
|
>
|
|
35
41
|
<X className="h-4 w-4" />
|
|
@@ -9,16 +9,20 @@ export interface TooltipProps {
|
|
|
9
9
|
content: string;
|
|
10
10
|
side?: "top" | "right" | "bottom" | "left";
|
|
11
11
|
className?: string;
|
|
12
|
+
/** Delay in ms before showing the tooltip (default 0) */
|
|
13
|
+
delay?: number;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
function Tooltip({
|
|
15
17
|
children,
|
|
16
18
|
content,
|
|
17
19
|
side = "right",
|
|
18
|
-
className
|
|
20
|
+
className,
|
|
21
|
+
delay = 0,
|
|
19
22
|
}: TooltipProps) {
|
|
20
23
|
const [isHovered, setIsHovered] = useState(false);
|
|
21
24
|
const [mounted, setMounted] = useState(false);
|
|
25
|
+
const delayRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
22
26
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
|
23
27
|
const [isPositioned, setIsPositioned] = useState(false);
|
|
24
28
|
const triggerRef = useRef<HTMLDivElement>(null);
|
|
@@ -26,6 +30,10 @@ function Tooltip({
|
|
|
26
30
|
|
|
27
31
|
useEffect(() => {
|
|
28
32
|
setMounted(true);
|
|
33
|
+
return () => {
|
|
34
|
+
// Clean up delay timer on unmount to prevent setState on unmounted component
|
|
35
|
+
if (delayRef.current) clearTimeout(delayRef.current);
|
|
36
|
+
};
|
|
29
37
|
}, []);
|
|
30
38
|
|
|
31
39
|
const updatePosition = useCallback(() => {
|
|
@@ -83,8 +91,17 @@ function Tooltip({
|
|
|
83
91
|
<>
|
|
84
92
|
<div
|
|
85
93
|
ref={triggerRef}
|
|
86
|
-
onMouseEnter={() =>
|
|
87
|
-
|
|
94
|
+
onMouseEnter={() => {
|
|
95
|
+
if (delay > 0) {
|
|
96
|
+
delayRef.current = setTimeout(() => setIsHovered(true), delay);
|
|
97
|
+
} else {
|
|
98
|
+
setIsHovered(true);
|
|
99
|
+
}
|
|
100
|
+
}}
|
|
101
|
+
onMouseLeave={() => {
|
|
102
|
+
if (delayRef.current) { clearTimeout(delayRef.current); delayRef.current = null; }
|
|
103
|
+
setIsHovered(false);
|
|
104
|
+
}}
|
|
88
105
|
className={className}
|
|
89
106
|
>
|
|
90
107
|
{children}
|
|
@@ -110,26 +127,26 @@ function Tooltip({
|
|
|
110
127
|
side === "bottom" && (isPositioned ? "translate-y-0" : "-translate-y-2")
|
|
111
128
|
)}
|
|
112
129
|
>
|
|
113
|
-
<div className="relative bg-
|
|
130
|
+
<div className="relative bg-[#1a1a1a] text-white text-sm font-medium px-3 py-1.5 rounded-[4px] whitespace-nowrap shadow-xl shadow-black/25">
|
|
114
131
|
{content}
|
|
115
132
|
{side === "right" && (
|
|
116
133
|
<div className="absolute -left-[7px] top-1/2 -translate-y-1/2">
|
|
117
|
-
<div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-r-[8px] border-r-
|
|
134
|
+
<div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-r-[8px] border-r-[#1a1a1a]" />
|
|
118
135
|
</div>
|
|
119
136
|
)}
|
|
120
137
|
{side === "left" && (
|
|
121
138
|
<div className="absolute -right-[7px] top-1/2 -translate-y-1/2">
|
|
122
|
-
<div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-l-[8px] border-l-
|
|
139
|
+
<div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-l-[8px] border-l-[#1a1a1a]" />
|
|
123
140
|
</div>
|
|
124
141
|
)}
|
|
125
142
|
{side === "top" && (
|
|
126
143
|
<div className="absolute -bottom-[7px] left-1/2 -translate-x-1/2">
|
|
127
|
-
<div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-
|
|
144
|
+
<div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-[#1a1a1a]" />
|
|
128
145
|
</div>
|
|
129
146
|
)}
|
|
130
147
|
{side === "bottom" && (
|
|
131
148
|
<div className="absolute -top-[7px] left-1/2 -translate-x-1/2">
|
|
132
|
-
<div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px] border-b-
|
|
149
|
+
<div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px] border-b-[#1a1a1a]" />
|
|
133
150
|
</div>
|
|
134
151
|
)}
|
|
135
152
|
</div>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useEffect, memo } from "react";
|
|
4
|
+
import twemoji from "@twemoji/api";
|
|
5
|
+
|
|
6
|
+
interface TwemojiTextProps {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wraps children and replaces native Unicode emojis with Twemoji SVG images.
|
|
13
|
+
* Uses twemoji.parse() on the rendered DOM node after mount/update.
|
|
14
|
+
*/
|
|
15
|
+
export const TwemojiText = memo(function TwemojiText({
|
|
16
|
+
children,
|
|
17
|
+
className,
|
|
18
|
+
}: TwemojiTextProps) {
|
|
19
|
+
const ref = useRef<HTMLSpanElement>(null);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (ref.current) {
|
|
23
|
+
twemoji.parse(ref.current, {
|
|
24
|
+
folder: "svg",
|
|
25
|
+
ext: ".svg",
|
|
26
|
+
// Use jsDelivr CDN for the SVG assets
|
|
27
|
+
base: "https://cdn.jsdelivr.net/gh/jdecked/twemoji@latest/assets/",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<span ref={ref} className={className}>
|
|
34
|
+
{children}
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Localhost guard — reject requests from non-local IPs
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
const LOCALHOST_IPS = new Set([
|
|
8
|
+
"127.0.0.1",
|
|
9
|
+
"::1",
|
|
10
|
+
"::ffff:127.0.0.1",
|
|
11
|
+
"localhost",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reject requests that don't originate from localhost.
|
|
16
|
+
* Uses x-forwarded-for (if behind a proxy) or falls back to the host header.
|
|
17
|
+
* Returns a 403 response if the request is from a non-local IP, or null if OK.
|
|
18
|
+
*/
|
|
19
|
+
export function checkLocalhost(request: NextRequest): NextResponse | null {
|
|
20
|
+
// If running in development, skip the check
|
|
21
|
+
if (process.env.NODE_ENV === "development") return null;
|
|
22
|
+
|
|
23
|
+
const forwarded = request.headers.get("x-forwarded-for");
|
|
24
|
+
const host = request.headers.get("host") || "";
|
|
25
|
+
|
|
26
|
+
// Check x-forwarded-for first (proxy scenario)
|
|
27
|
+
if (forwarded) {
|
|
28
|
+
const clientIp = forwarded.split(",")[0].trim();
|
|
29
|
+
if (!LOCALHOST_IPS.has(clientIp)) {
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{ error: "Forbidden — Castle is only accessible from localhost" },
|
|
32
|
+
{ status: 403 }
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Check host header — reject if it's not a localhost variant
|
|
38
|
+
const hostname = host.split(":")[0];
|
|
39
|
+
if (hostname && !LOCALHOST_IPS.has(hostname)) {
|
|
40
|
+
return NextResponse.json(
|
|
41
|
+
{ error: "Forbidden — Castle is only accessible from localhost" },
|
|
42
|
+
{ status: 403 }
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// Rate limiting — in-memory sliding window
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
interface RateLimitEntry {
|
|
54
|
+
timestamps: number[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const rateLimitStore = new Map<string, RateLimitEntry>();
|
|
58
|
+
|
|
59
|
+
// Clean up stale entries every 5 minutes
|
|
60
|
+
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
61
|
+
function ensureCleanup() {
|
|
62
|
+
if (cleanupTimer) return;
|
|
63
|
+
cleanupTimer = setInterval(() => {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
for (const [key, entry] of rateLimitStore.entries()) {
|
|
66
|
+
entry.timestamps = entry.timestamps.filter((t) => now - t < 60_000);
|
|
67
|
+
if (entry.timestamps.length === 0) rateLimitStore.delete(key);
|
|
68
|
+
}
|
|
69
|
+
}, 5 * 60_000);
|
|
70
|
+
// Don't prevent process exit
|
|
71
|
+
if (typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
|
|
72
|
+
cleanupTimer.unref();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Simple in-memory rate limiter using a sliding window.
|
|
78
|
+
*
|
|
79
|
+
* @param key — Unique key for the rate limit bucket (e.g. "chat:send" or IP-based)
|
|
80
|
+
* @param limit — Max requests allowed within the window
|
|
81
|
+
* @param windowMs — Window duration in milliseconds (default: 60 seconds)
|
|
82
|
+
* @returns 429 response if rate limit exceeded, or null if OK
|
|
83
|
+
*/
|
|
84
|
+
export function checkRateLimit(
|
|
85
|
+
key: string,
|
|
86
|
+
limit: number,
|
|
87
|
+
windowMs = 60_000
|
|
88
|
+
): NextResponse | null {
|
|
89
|
+
ensureCleanup();
|
|
90
|
+
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
let entry = rateLimitStore.get(key);
|
|
93
|
+
|
|
94
|
+
if (!entry) {
|
|
95
|
+
entry = { timestamps: [] };
|
|
96
|
+
rateLimitStore.set(key, entry);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Drop timestamps outside the window
|
|
100
|
+
entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
|
|
101
|
+
|
|
102
|
+
if (entry.timestamps.length >= limit) {
|
|
103
|
+
return NextResponse.json(
|
|
104
|
+
{ error: "Too many requests — please slow down" },
|
|
105
|
+
{ status: 429 }
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
entry.timestamps.push(now);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Helper to build a rate-limit key from the request IP + route.
|
|
115
|
+
*/
|
|
116
|
+
export function rateLimitKey(request: NextRequest, route: string): string {
|
|
117
|
+
const ip =
|
|
118
|
+
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ||
|
|
119
|
+
request.headers.get("x-real-ip") ||
|
|
120
|
+
"local";
|
|
121
|
+
return `${ip}:${route}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================================
|
|
125
|
+
// CSRF protection
|
|
126
|
+
// ============================================================================
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Verify that a mutating API request originated from the Castle UI itself,
|
|
130
|
+
* not from a cross-origin attacker (CSRF protection).
|
|
131
|
+
*
|
|
132
|
+
* Checks the Origin or Referer header against allowed localhost origins.
|
|
133
|
+
* Returns a 403 response if the request fails the check, or null if it passes.
|
|
134
|
+
*/
|
|
135
|
+
export function checkCsrf(request: NextRequest): NextResponse | null {
|
|
136
|
+
const origin = request.headers.get("origin");
|
|
137
|
+
const referer = request.headers.get("referer");
|
|
138
|
+
|
|
139
|
+
// Build allowed origins from the request's own host
|
|
140
|
+
const host = request.headers.get("host") || "localhost:3333";
|
|
141
|
+
const allowed = new Set([
|
|
142
|
+
`http://${host}`,
|
|
143
|
+
`https://${host}`,
|
|
144
|
+
// Common localhost variants
|
|
145
|
+
"http://localhost:3333",
|
|
146
|
+
"http://127.0.0.1:3333",
|
|
147
|
+
"http://[::1]:3333",
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
// If Origin header is present, it must match
|
|
151
|
+
if (origin) {
|
|
152
|
+
if (allowed.has(origin)) return null;
|
|
153
|
+
return NextResponse.json(
|
|
154
|
+
{ error: "Forbidden — cross-origin request rejected" },
|
|
155
|
+
{ status: 403 }
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Fall back to Referer (browsers always send at least one for form submissions)
|
|
160
|
+
if (referer) {
|
|
161
|
+
try {
|
|
162
|
+
const refOrigin = new URL(referer).origin;
|
|
163
|
+
if (allowed.has(refOrigin)) return null;
|
|
164
|
+
} catch {
|
|
165
|
+
// Malformed referer
|
|
166
|
+
}
|
|
167
|
+
return NextResponse.json(
|
|
168
|
+
{ error: "Forbidden — cross-origin request rejected" },
|
|
169
|
+
{ status: 403 }
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// No Origin or Referer — likely a direct curl/CLI call, allow it.
|
|
174
|
+
// Browsers ALWAYS send Origin on cross-origin requests,
|
|
175
|
+
// so a missing Origin means it's not a browser-based CSRF attack.
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Sanitize a string by redacting token patterns and key material.
|
|
181
|
+
* Use this before returning any text content (logs, errors) via API.
|
|
182
|
+
*/
|
|
183
|
+
export function sanitizeForApi(str: string): string {
|
|
184
|
+
return str
|
|
185
|
+
.replace(/rew_[a-f0-9]+/gi, "rew_***")
|
|
186
|
+
.replace(/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, "[REDACTED KEY]")
|
|
187
|
+
.replace(/\b[a-f0-9]{32,}\b/gi, (m) => m.slice(0, 8) + "***");
|
|
188
|
+
}
|