@castlekit/castle 0.1.6 → 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 +20 -3
- package/src/app/api/avatars/[id]/route.ts +57 -7
- 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/logs/route.ts +17 -3
- package/src/app/api/openclaw/restart/route.ts +6 -1
- 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 +49 -17
- 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 +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 +125 -0
- 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/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,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ChevronDown, ChevronUp, Brain, Zap } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import type { SessionStatus } from "@/lib/types/chat";
|
|
7
|
+
|
|
8
|
+
interface SessionStatsPanelProps {
|
|
9
|
+
stats: SessionStatus | null;
|
|
10
|
+
isLoading?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Simple relative time helper — avoids adding date-fns dependency */
|
|
15
|
+
function timeAgo(timestamp: number): string {
|
|
16
|
+
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
17
|
+
if (seconds < 5) return "just now";
|
|
18
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
19
|
+
const minutes = Math.floor(seconds / 60);
|
|
20
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
21
|
+
const hours = Math.floor(minutes / 60);
|
|
22
|
+
if (hours < 24) return `${hours}h ago`;
|
|
23
|
+
const days = Math.floor(hours / 24);
|
|
24
|
+
return `${days}d ago`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Get context bar color based on usage percentage */
|
|
28
|
+
function getContextColor(percentage: number): string {
|
|
29
|
+
if (percentage >= 90) return "bg-error";
|
|
30
|
+
if (percentage >= 80) return "bg-orange-500";
|
|
31
|
+
if (percentage >= 60) return "bg-yellow-500";
|
|
32
|
+
return "bg-success";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatTokens(n: number): string {
|
|
36
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
37
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
38
|
+
return String(n);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function SessionStatsPanel({ stats, isLoading, className }: SessionStatsPanelProps) {
|
|
42
|
+
const [expanded, setExpanded] = useState(false);
|
|
43
|
+
|
|
44
|
+
if (!stats && !isLoading) return null;
|
|
45
|
+
|
|
46
|
+
const percentage = stats?.context?.percentage ?? 0;
|
|
47
|
+
const contextColor = getContextColor(percentage);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className={cn("border-b border-border", className)}>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={() => setExpanded(!expanded)}
|
|
54
|
+
className="w-full px-4 py-2 flex items-center justify-between text-xs text-foreground-secondary hover:bg-surface-hover/50 transition-colors"
|
|
55
|
+
>
|
|
56
|
+
{/* Collapsed view */}
|
|
57
|
+
{stats ? (
|
|
58
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
59
|
+
<span className="flex items-center gap-1">
|
|
60
|
+
<span className={cn("w-2 h-2 rounded-full", contextColor)} />
|
|
61
|
+
Context: {formatTokens(stats.context.used)}/{formatTokens(stats.context.limit)} ({percentage}%)
|
|
62
|
+
</span>
|
|
63
|
+
<span className="text-foreground-secondary/60">|</span>
|
|
64
|
+
<span>
|
|
65
|
+
Tokens: {formatTokens(stats.tokens.input)} in / {formatTokens(stats.tokens.output)} out
|
|
66
|
+
</span>
|
|
67
|
+
</div>
|
|
68
|
+
) : (
|
|
69
|
+
<span className="text-foreground-secondary/60">
|
|
70
|
+
{isLoading ? "Loading session stats..." : "No session data"}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
{expanded ? (
|
|
74
|
+
<ChevronUp className="h-3 w-3 shrink-0 ml-2" />
|
|
75
|
+
) : (
|
|
76
|
+
<ChevronDown className="h-3 w-3 shrink-0 ml-2" />
|
|
77
|
+
)}
|
|
78
|
+
</button>
|
|
79
|
+
|
|
80
|
+
{/* Expanded view */}
|
|
81
|
+
{expanded && stats && (
|
|
82
|
+
<div className="px-4 pb-3 space-y-3">
|
|
83
|
+
{/* Context progress bar */}
|
|
84
|
+
<div>
|
|
85
|
+
<div className="flex items-center justify-between text-xs mb-1">
|
|
86
|
+
<span className="text-foreground-secondary">Context Window</span>
|
|
87
|
+
<span className="font-mono">
|
|
88
|
+
{formatTokens(stats.context.used)} / {formatTokens(stats.context.limit)}
|
|
89
|
+
</span>
|
|
90
|
+
</div>
|
|
91
|
+
<div className="w-full h-2 bg-surface-hover rounded-full overflow-hidden">
|
|
92
|
+
<div
|
|
93
|
+
className={cn("h-full rounded-full transition-all duration-300", contextColor)}
|
|
94
|
+
style={{ width: `${Math.min(percentage, 100)}%` }}
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{/* Details grid */}
|
|
100
|
+
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
|
|
101
|
+
<div className="flex items-center gap-2">
|
|
102
|
+
<span className="text-foreground-secondary">Model</span>
|
|
103
|
+
<span className="font-medium truncate">{stats.model}</span>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="flex items-center gap-2">
|
|
106
|
+
<span className="text-foreground-secondary">Runtime</span>
|
|
107
|
+
<span className="font-medium">{stats.runtime}</span>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="flex items-center gap-2">
|
|
110
|
+
<Brain className="h-3 w-3 text-foreground-secondary" />
|
|
111
|
+
<span className="text-foreground-secondary">Thinking</span>
|
|
112
|
+
<span className="font-medium">{stats.thinking || "off"}</span>
|
|
113
|
+
</div>
|
|
114
|
+
<div className="flex items-center gap-2">
|
|
115
|
+
<Zap className="h-3 w-3 text-foreground-secondary" />
|
|
116
|
+
<span className="text-foreground-secondary">Compactions</span>
|
|
117
|
+
<span className="font-medium">{stats.compactions}</span>
|
|
118
|
+
</div>
|
|
119
|
+
<div className="flex items-center gap-2">
|
|
120
|
+
<span className="text-foreground-secondary">Input tokens</span>
|
|
121
|
+
<span className="font-medium">{stats.tokens.input.toLocaleString()}</span>
|
|
122
|
+
</div>
|
|
123
|
+
<div className="flex items-center gap-2">
|
|
124
|
+
<span className="text-foreground-secondary">Output tokens</span>
|
|
125
|
+
<span className="font-medium">{stats.tokens.output.toLocaleString()}</span>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Updated timestamp */}
|
|
130
|
+
{stats.updatedAt && (
|
|
131
|
+
<div className="text-xs text-foreground-secondary/60 text-right">
|
|
132
|
+
Updated {timeAgo(stats.updatedAt)}
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { HardDrive, AlertTriangle } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
interface StorageData {
|
|
8
|
+
messages: number;
|
|
9
|
+
channels: number;
|
|
10
|
+
attachments: number;
|
|
11
|
+
totalAttachmentBytes: number;
|
|
12
|
+
dbSizeBytes: number;
|
|
13
|
+
attachmentsDirBytes: number;
|
|
14
|
+
warnings: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatBytes(bytes: number): string {
|
|
18
|
+
if (bytes === 0) return "0 B";
|
|
19
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
20
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
21
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
22
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface StorageIndicatorProps {
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function StorageIndicator({ className }: StorageIndicatorProps) {
|
|
30
|
+
const [data, setData] = useState<StorageData | null>(null);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
async function fetchStats() {
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch("/api/openclaw/chat/storage");
|
|
36
|
+
if (res.ok) {
|
|
37
|
+
setData(await res.json());
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// Silently fail — storage indicator is non-critical
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fetchStats();
|
|
45
|
+
// Refresh every 5 minutes
|
|
46
|
+
const interval = setInterval(fetchStats, 300000);
|
|
47
|
+
return () => clearInterval(interval);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
if (!data) return null;
|
|
51
|
+
|
|
52
|
+
const hasWarnings = data.warnings && data.warnings.length > 0;
|
|
53
|
+
const totalSize = (data.dbSizeBytes || 0) + (data.attachmentsDirBytes || 0);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className={cn("px-4 py-2 border-t border-border", className)}>
|
|
57
|
+
<div className="flex items-center justify-between text-xs text-foreground-secondary">
|
|
58
|
+
<div className="flex items-center gap-2">
|
|
59
|
+
{hasWarnings ? (
|
|
60
|
+
<AlertTriangle className="h-3 w-3 text-warning" />
|
|
61
|
+
) : (
|
|
62
|
+
<HardDrive className="h-3 w-3" />
|
|
63
|
+
)}
|
|
64
|
+
<span>
|
|
65
|
+
{data.messages.toLocaleString()} messages · {formatBytes(totalSize)}
|
|
66
|
+
</span>
|
|
67
|
+
</div>
|
|
68
|
+
{hasWarnings && (
|
|
69
|
+
<span className="text-warning text-xs truncate ml-2 max-w-[200px]" title={data.warnings.join("; ")}>
|
|
70
|
+
{data.warnings[0]}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -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,81 @@
|
|
|
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
|
+
// Provider
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
export function SearchProvider({ children }: { children: React.ReactNode }) {
|
|
33
|
+
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
|
34
|
+
const [isMac, setIsMac] = useState(true);
|
|
35
|
+
const pathname = usePathname();
|
|
36
|
+
|
|
37
|
+
// Detect platform for keyboard shortcut display
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
setIsMac(navigator.platform?.toUpperCase().includes("MAC") ?? true);
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
// Hide the floating search trigger on pages where it would overlap or isn't relevant
|
|
43
|
+
const showSearchBar = pathname !== "/settings" && pathname !== "/ui-kit";
|
|
44
|
+
|
|
45
|
+
const openSearch = useCallback(() => setIsSearchOpen(true), []);
|
|
46
|
+
const closeSearch = useCallback(() => setIsSearchOpen(false), []);
|
|
47
|
+
|
|
48
|
+
// Global shortcut: Cmd+K / Ctrl+K to open search
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const handler = (e: KeyboardEvent) => {
|
|
51
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
setIsSearchOpen((prev) => !prev);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
document.addEventListener("keydown", handler);
|
|
58
|
+
return () => document.removeEventListener("keydown", handler);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<SearchContext.Provider value={{ isSearchOpen, openSearch, closeSearch }}>
|
|
63
|
+
{children}
|
|
64
|
+
{/* Floating search trigger — top right (hidden on settings/ui-kit) */}
|
|
65
|
+
{showSearchBar && (
|
|
66
|
+
<button
|
|
67
|
+
onClick={openSearch}
|
|
68
|
+
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"
|
|
69
|
+
>
|
|
70
|
+
<Search className="h-4 w-4 shrink-0" strokeWidth={2.5} />
|
|
71
|
+
<span className="text-sm text-foreground-secondary/50 flex-1 text-left">Search Castle...</span>
|
|
72
|
+
<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">
|
|
73
|
+
{isMac ? <span className="text-[15px]">⌘</span> : <span className="text-[11px]">Ctrl</span>}
|
|
74
|
+
<span className="text-[11px]">K</span>
|
|
75
|
+
</kbd>
|
|
76
|
+
</button>
|
|
77
|
+
)}
|
|
78
|
+
<SearchDialog open={isSearchOpen} onClose={closeSearch} />
|
|
79
|
+
</SearchContext.Provider>
|
|
80
|
+
);
|
|
81
|
+
}
|