@castlekit/castle 0.1.6 → 0.3.1
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/LICENSE +21 -0
- package/drizzle.config.ts +7 -0
- package/install.sh +20 -1
- package/next.config.ts +1 -0
- package/package.json +35 -3
- package/src/app/api/avatars/[id]/route.ts +57 -7
- package/src/app/api/openclaw/agents/route.ts +7 -1
- 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 +217 -0
- package/src/app/api/openclaw/chat/route.ts +283 -0
- package/src/app/api/openclaw/chat/search/route.ts +150 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +2 -0
- package/src/app/api/openclaw/events/route.ts +23 -8
- package/src/app/api/openclaw/logs/route.ts +17 -3
- package/src/app/api/openclaw/ping/route.ts +5 -0
- package/src/app/api/openclaw/restart/route.ts +6 -1
- package/src/app/api/openclaw/session/context/route.ts +163 -0
- package/src/app/api/openclaw/session/status/route.ts +210 -0
- package/src/app/api/openclaw/sessions/route.ts +2 -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 +385 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +99 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +59 -25
- package/src/app/settings/page.tsx +300 -0
- 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 +328 -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 +168 -0
- package/src/components/chat/message-list.tsx +666 -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 +444 -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 +110 -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 +125 -0
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/index.ts +652 -0
- package/src/lib/db/queries.ts +1144 -0
- package/src/lib/db/schema.ts +164 -0
- package/src/lib/gateway-connection.ts +24 -3
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +753 -0
- package/src/lib/hooks/use-compaction-events.ts +132 -0
- package/src/lib/hooks/use-context-boundary.ts +82 -0
- package/src/lib/hooks/use-openclaw.ts +122 -100
- package/src/lib/hooks/use-search.ts +114 -0
- package/src/lib/hooks/use-session-stats.ts +60 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/sse-singleton.ts +184 -0
- package/src/lib/types/chat.ts +202 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useState, useRef, useEffect } from "react";
|
|
4
|
+
import { useTheme } from "next-themes";
|
|
3
5
|
import {
|
|
4
6
|
LayoutDashboard,
|
|
7
|
+
MessageCircle,
|
|
8
|
+
User,
|
|
9
|
+
Sun,
|
|
10
|
+
Moon,
|
|
11
|
+
Settings,
|
|
12
|
+
Loader2,
|
|
5
13
|
type LucideIcon,
|
|
6
14
|
} from "lucide-react";
|
|
7
|
-
import { Fragment } from "react";
|
|
8
15
|
import { CastleIcon } from "@/components/icons/castle-icon";
|
|
9
16
|
import { Tooltip } from "@/components/ui/tooltip";
|
|
10
17
|
import { cn } from "@/lib/utils";
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
18
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
19
|
+
import { useUserSettings } from "@/lib/hooks/use-user-settings";
|
|
20
|
+
// Search is now a floating bar (top-right), no longer in sidebar
|
|
21
|
+
import { useAgentStatus, USER_STATUS_ID } from "@/lib/hooks/use-agent-status";
|
|
22
|
+
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
|
13
23
|
|
|
14
24
|
interface NavItem {
|
|
15
25
|
id: string;
|
|
@@ -32,6 +42,12 @@ const navItems: NavItem[] = [
|
|
|
32
42
|
icon: LayoutDashboard,
|
|
33
43
|
href: "/",
|
|
34
44
|
},
|
|
45
|
+
{
|
|
46
|
+
id: "chat",
|
|
47
|
+
label: "Chat",
|
|
48
|
+
icon: MessageCircle,
|
|
49
|
+
href: "/chat",
|
|
50
|
+
},
|
|
35
51
|
];
|
|
36
52
|
|
|
37
53
|
function Sidebar({
|
|
@@ -41,11 +57,13 @@ function Sidebar({
|
|
|
41
57
|
variant = "solid"
|
|
42
58
|
}: SidebarProps) {
|
|
43
59
|
const pathname = usePathname();
|
|
60
|
+
const router = useRouter();
|
|
44
61
|
const useLinks = !onNavigate;
|
|
45
|
-
|
|
62
|
+
const { tooltips: showTooltips } = useUserSettings();
|
|
46
63
|
const activeFromPath = (() => {
|
|
47
64
|
if (!pathname) return "dashboard";
|
|
48
65
|
if (pathname === "/") return "dashboard";
|
|
66
|
+
if (pathname.startsWith("/chat")) return "chat";
|
|
49
67
|
return "dashboard";
|
|
50
68
|
})();
|
|
51
69
|
|
|
@@ -54,54 +72,32 @@ function Sidebar({
|
|
|
54
72
|
return (
|
|
55
73
|
<aside
|
|
56
74
|
className={cn(
|
|
57
|
-
"fixed top-
|
|
75
|
+
"fixed top-[20px] left-[24px] bottom-[20px] flex flex-col z-40 rounded-[var(--radius-md)] w-14",
|
|
58
76
|
variant === "glass" ? "glass" : "bg-surface border border-border",
|
|
59
77
|
className
|
|
60
78
|
)}
|
|
61
79
|
>
|
|
62
80
|
{/* Header */}
|
|
63
81
|
<div className="flex items-center justify-center pt-5 pb-[60px]">
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
) : (
|
|
73
|
-
<button
|
|
74
|
-
type="button"
|
|
75
|
-
aria-label="Go to Dashboard"
|
|
76
|
-
onClick={() => onNavigate?.("dashboard")}
|
|
77
|
-
className="flex items-center justify-center transition-opacity hover:opacity-85 cursor-pointer"
|
|
78
|
-
>
|
|
79
|
-
<CastleIcon className="h-[36px] w-[36px] min-h-[36px] min-w-[36px] shrink-0 text-[var(--logo-color)] -mt-[3px]" />
|
|
80
|
-
</button>
|
|
81
|
-
)}
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
aria-label="Go to Dashboard"
|
|
85
|
+
onClick={() => useLinks ? router.push("/") : onNavigate?.("dashboard")}
|
|
86
|
+
className="flex items-center justify-center transition-opacity hover:opacity-85 cursor-pointer"
|
|
87
|
+
>
|
|
88
|
+
<CastleIcon className="h-[36px] w-[36px] min-h-[36px] min-w-[36px] shrink-0 text-[var(--logo-color)] -mt-[3px]" />
|
|
89
|
+
</button>
|
|
82
90
|
</div>
|
|
83
91
|
|
|
84
92
|
{/* Navigation */}
|
|
85
93
|
<nav className="flex-1 space-y-1 px-2">
|
|
86
94
|
{navItems.map((item) => {
|
|
87
95
|
const isActive = effectiveActive === item.id;
|
|
88
|
-
const NavEl =
|
|
89
|
-
<Link
|
|
90
|
-
href={item.href}
|
|
91
|
-
className={cn(
|
|
92
|
-
"flex items-center justify-center w-full rounded-[20px] p-2.5 cursor-pointer",
|
|
93
|
-
isActive
|
|
94
|
-
? "bg-accent/10 text-accent"
|
|
95
|
-
: "text-foreground-secondary hover:text-foreground hover:bg-surface-hover"
|
|
96
|
-
)}
|
|
97
|
-
>
|
|
98
|
-
<item.icon className="h-5 w-5 shrink-0" />
|
|
99
|
-
</Link>
|
|
100
|
-
) : (
|
|
96
|
+
const NavEl = (
|
|
101
97
|
<button
|
|
102
|
-
onClick={() => onNavigate?.(item.id)}
|
|
98
|
+
onClick={() => useLinks ? router.push(item.href) : onNavigate?.(item.id)}
|
|
103
99
|
className={cn(
|
|
104
|
-
"flex items-center justify-center w-full rounded-[
|
|
100
|
+
"flex items-center justify-center w-full rounded-[4px] p-2.5 cursor-pointer",
|
|
105
101
|
isActive
|
|
106
102
|
? "bg-accent/10 text-accent"
|
|
107
103
|
: "text-foreground-secondary hover:text-foreground hover:bg-surface-hover"
|
|
@@ -111,18 +107,103 @@ function Sidebar({
|
|
|
111
107
|
</button>
|
|
112
108
|
);
|
|
113
109
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
{
|
|
117
|
-
|
|
118
|
-
|
|
110
|
+
if (showTooltips) {
|
|
111
|
+
return (
|
|
112
|
+
<Tooltip key={item.id} content={item.label} side="right">
|
|
113
|
+
{NavEl}
|
|
114
|
+
</Tooltip>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return <div key={item.id}>{NavEl}</div>;
|
|
119
118
|
})}
|
|
120
119
|
</nav>
|
|
121
120
|
|
|
122
|
-
{/*
|
|
123
|
-
<
|
|
121
|
+
{/* User menu at bottom */}
|
|
122
|
+
<SidebarUserMenu />
|
|
124
123
|
</aside>
|
|
125
124
|
);
|
|
126
125
|
}
|
|
127
126
|
|
|
127
|
+
function SidebarUserMenu() {
|
|
128
|
+
const [open, setOpen] = useState(false);
|
|
129
|
+
const { theme, setTheme } = useTheme();
|
|
130
|
+
const [mounted, setMounted] = useState(false);
|
|
131
|
+
const router = useRouter();
|
|
132
|
+
const { avatarUrl, isLoading: settingsLoading } = useUserSettings();
|
|
133
|
+
const { getStatus } = useAgentStatus();
|
|
134
|
+
const userStatus = getStatus(USER_STATUS_ID);
|
|
135
|
+
const avatarDotStatus = userStatus === "active" ? "online" as const : "offline" as const;
|
|
136
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
setMounted(true);
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
function handleClickOutside(event: MouseEvent) {
|
|
144
|
+
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
145
|
+
setOpen(false);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
149
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
150
|
+
}, []);
|
|
151
|
+
|
|
152
|
+
const isDark = theme === "dark";
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div ref={menuRef} className="relative flex justify-center pb-[8px]">
|
|
156
|
+
<button
|
|
157
|
+
onClick={() => setOpen(!open)}
|
|
158
|
+
className="group flex items-center justify-center rounded-[4px] cursor-pointer transition-opacity"
|
|
159
|
+
>
|
|
160
|
+
<Avatar size="sm" status={avatarDotStatus}>
|
|
161
|
+
{settingsLoading ? (
|
|
162
|
+
<AvatarFallback className="skeleton" />
|
|
163
|
+
) : avatarUrl ? (
|
|
164
|
+
<AvatarImage
|
|
165
|
+
src={avatarUrl}
|
|
166
|
+
alt="You"
|
|
167
|
+
className="grayscale group-hover:grayscale-0 transition-all duration-200"
|
|
168
|
+
/>
|
|
169
|
+
) : (
|
|
170
|
+
<AvatarFallback className="text-foreground-secondary">
|
|
171
|
+
<User className="h-5 w-5" />
|
|
172
|
+
</AvatarFallback>
|
|
173
|
+
)}
|
|
174
|
+
</Avatar>
|
|
175
|
+
</button>
|
|
176
|
+
|
|
177
|
+
{open && (
|
|
178
|
+
<div className="absolute left-[calc(100%+8px)] bottom-0 w-48 rounded-[var(--radius-md)] bg-surface border border-border shadow-xl py-1 z-50">
|
|
179
|
+
<button
|
|
180
|
+
onClick={() => { setOpen(false); router.push("/settings"); }}
|
|
181
|
+
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-foreground-secondary hover:text-foreground hover:bg-surface-hover cursor-pointer"
|
|
182
|
+
>
|
|
183
|
+
<Settings className="h-4 w-4" />
|
|
184
|
+
Settings
|
|
185
|
+
</button>
|
|
186
|
+
|
|
187
|
+
{mounted && (
|
|
188
|
+
<button
|
|
189
|
+
onClick={() => {
|
|
190
|
+
setTheme(isDark ? "light" : "dark");
|
|
191
|
+
setOpen(false);
|
|
192
|
+
}}
|
|
193
|
+
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-foreground-secondary hover:text-foreground hover:bg-surface-hover cursor-pointer"
|
|
194
|
+
>
|
|
195
|
+
{isDark ? (
|
|
196
|
+
<Sun className="h-4 w-4" />
|
|
197
|
+
) : (
|
|
198
|
+
<Moon className="h-4 w-4" />
|
|
199
|
+
)}
|
|
200
|
+
{isDark ? "Light mode" : "Dark mode"}
|
|
201
|
+
</button>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
128
209
|
export { Sidebar };
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useRef, useEffect } from "react";
|
|
4
4
|
import { useTheme } from "next-themes";
|
|
5
|
-
import {
|
|
5
|
+
import { useRouter } from "next/navigation";
|
|
6
|
+
import { User, Sun, Moon, Settings } from "lucide-react";
|
|
6
7
|
import { cn } from "@/lib/utils";
|
|
8
|
+
import { useUserSettings } from "@/lib/hooks/use-user-settings";
|
|
7
9
|
|
|
8
10
|
export interface UserMenuProps {
|
|
9
11
|
className?: string;
|
|
@@ -14,6 +16,8 @@ function UserMenu({ className, variant = "solid" }: UserMenuProps) {
|
|
|
14
16
|
const [open, setOpen] = useState(false);
|
|
15
17
|
const { theme, setTheme } = useTheme();
|
|
16
18
|
const [mounted, setMounted] = useState(false);
|
|
19
|
+
const router = useRouter();
|
|
20
|
+
const { avatarUrl } = useUserSettings();
|
|
17
21
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
18
22
|
|
|
19
23
|
useEffect(() => {
|
|
@@ -37,15 +41,36 @@ function UserMenu({ className, variant = "solid" }: UserMenuProps) {
|
|
|
37
41
|
<button
|
|
38
42
|
onClick={() => setOpen(!open)}
|
|
39
43
|
className={cn(
|
|
40
|
-
"flex items-center justify-center h-14 w-14 rounded-[28px] shadow-xl shadow-black/20
|
|
41
|
-
|
|
44
|
+
"flex items-center justify-center h-14 w-14 rounded-[28px] shadow-xl shadow-black/20 cursor-pointer overflow-hidden",
|
|
45
|
+
avatarUrl
|
|
46
|
+
? ""
|
|
47
|
+
: cn(
|
|
48
|
+
"text-foreground-secondary hover:text-foreground",
|
|
49
|
+
variant === "glass" ? "glass" : "bg-surface border border-border"
|
|
50
|
+
)
|
|
42
51
|
)}
|
|
43
52
|
>
|
|
44
|
-
|
|
53
|
+
{avatarUrl ? (
|
|
54
|
+
<img
|
|
55
|
+
src={avatarUrl}
|
|
56
|
+
alt="You"
|
|
57
|
+
className="w-full h-full object-cover"
|
|
58
|
+
/>
|
|
59
|
+
) : (
|
|
60
|
+
<User className="h-5 w-5" />
|
|
61
|
+
)}
|
|
45
62
|
</button>
|
|
46
63
|
|
|
47
64
|
{open && (
|
|
48
65
|
<div className="absolute right-0 top-[calc(100%+8px)] w-48 rounded-[var(--radius-md)] bg-surface border border-border shadow-xl py-1 z-50">
|
|
66
|
+
<button
|
|
67
|
+
onClick={() => { setOpen(false); router.push("/settings"); }}
|
|
68
|
+
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-foreground-secondary hover:text-foreground hover:bg-surface-hover cursor-pointer"
|
|
69
|
+
>
|
|
70
|
+
<Settings className="h-4 w-4" />
|
|
71
|
+
Settings
|
|
72
|
+
</button>
|
|
73
|
+
|
|
49
74
|
{mounted && (
|
|
50
75
|
<button
|
|
51
76
|
onClick={() => {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useEffect, useCallback } from "react";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { Search } from "lucide-react";
|
|
6
|
+
import { SearchDialog } from "@/components/search/search-dialog";
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Context
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
interface SearchContextValue {
|
|
13
|
+
isSearchOpen: boolean;
|
|
14
|
+
openSearch: () => void;
|
|
15
|
+
closeSearch: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SearchContext = createContext<SearchContextValue>({
|
|
19
|
+
isSearchOpen: false,
|
|
20
|
+
openSearch: () => {},
|
|
21
|
+
closeSearch: () => {},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function useSearchContext() {
|
|
25
|
+
return useContext(SearchContext);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Reusable search trigger button (flow element — no fixed positioning)
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
export function SearchTrigger({ className }: { className?: string }) {
|
|
33
|
+
const { openSearch } = useSearchContext();
|
|
34
|
+
const [isMac, setIsMac] = useState(true);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
setIsMac(navigator.platform?.toUpperCase().includes("MAC") ?? true);
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<button
|
|
42
|
+
onClick={openSearch}
|
|
43
|
+
className={className ?? "flex items-center gap-3 pl-3 pr-2.5 h-[38px] w-[320px] rounded-[var(--radius-sm)] bg-surface border border-border hover:border-border-hover text-foreground-secondary hover:text-foreground transition-colors cursor-pointer shadow-sm"}
|
|
44
|
+
>
|
|
45
|
+
<Search className="h-4 w-4 shrink-0" strokeWidth={2.5} />
|
|
46
|
+
<span className="text-sm text-foreground-secondary/50 flex-1 text-left">Search Castle...</span>
|
|
47
|
+
<kbd className="flex items-center justify-center h-[22px] px-1.5 gap-1 rounded-[4px] bg-surface-hover border border-border font-medium text-foreground-secondary">
|
|
48
|
+
{isMac ? <span className="text-[15px]">⌘</span> : <span className="text-[11px]">Ctrl</span>}
|
|
49
|
+
<span className="text-[11px]">K</span>
|
|
50
|
+
</kbd>
|
|
51
|
+
</button>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// Provider
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
export function SearchProvider({ children }: { children: React.ReactNode }) {
|
|
60
|
+
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
61
|
+
const [isMac, setIsMac] = useState(true);
|
|
62
|
+
const pathname = usePathname();
|
|
63
|
+
|
|
64
|
+
// Detect platform for keyboard shortcut display
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
setIsMac(navigator.platform?.toUpperCase().includes("MAC") ?? true);
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
// Hide floating trigger on pages that render their own SearchTrigger in the header
|
|
70
|
+
const showFloatingSearch =
|
|
71
|
+
!["/settings", "/ui-kit", "/"].includes(pathname) &&
|
|
72
|
+
!pathname.startsWith("/chat");
|
|
73
|
+
|
|
74
|
+
const openSearch = useCallback(() => setIsSearchOpen(true), []);
|
|
75
|
+
const closeSearch = useCallback(() => setIsSearchOpen(false), []);
|
|
76
|
+
|
|
77
|
+
// Global shortcut: Cmd+K / Ctrl+K to open search
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const handler = (e: KeyboardEvent) => {
|
|
80
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
setIsSearchOpen((prev) => !prev);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
document.addEventListener("keydown", handler);
|
|
87
|
+
return () => document.removeEventListener("keydown", handler);
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<SearchContext.Provider value={{ isSearchOpen, openSearch, closeSearch }}>
|
|
92
|
+
{children}
|
|
93
|
+
{/* Floating search trigger — only on pages without an embedded header trigger */}
|
|
94
|
+
{showFloatingSearch && (
|
|
95
|
+
<button
|
|
96
|
+
onClick={openSearch}
|
|
97
|
+
className="fixed top-[28px] right-[28px] z-40 flex items-center gap-3 pl-3 pr-2.5 h-[38px] w-[320px] rounded-[var(--radius-sm)] bg-surface border border-border hover:border-border-hover text-foreground-secondary hover:text-foreground transition-colors cursor-pointer shadow-sm"
|
|
98
|
+
>
|
|
99
|
+
<Search className="h-4 w-4 shrink-0" strokeWidth={2.5} />
|
|
100
|
+
<span className="text-sm text-foreground-secondary/50 flex-1 text-left">Search Castle...</span>
|
|
101
|
+
<kbd className="flex items-center justify-center h-[22px] px-1.5 gap-1 rounded-[4px] bg-surface-hover border border-border font-medium text-foreground-secondary">
|
|
102
|
+
{isMac ? <span className="text-[15px]">⌘</span> : <span className="text-[11px]">Ctrl</span>}
|
|
103
|
+
<span className="text-[11px]">K</span>
|
|
104
|
+
</kbd>
|
|
105
|
+
</button>
|
|
106
|
+
)}
|
|
107
|
+
<SearchDialog open={isSearchOpen} onClose={closeSearch} />
|
|
108
|
+
</SearchContext.Provider>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -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
|
+
}
|