create-supyagent-app 0.1.36 → 0.1.38
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/dist/index.js +179 -185
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/api-route/compact/route.ts.tmpl +30 -0
- package/templates/api-route/route.skills.ts.tmpl +54 -2
- package/templates/api-route/route.tools.ts.tmpl +54 -2
- package/templates/base/src/app/layout.tsx +12 -2
- package/templates/base/src/components/chat-input.tsx +58 -2
- package/templates/base/src/components/chat-message.tsx +10 -0
- package/templates/base/src/components/chat.tsx +164 -23
- package/templates/base/src/components/header.tsx +15 -1
- package/templates/base/src/components/slash-menu.tsx +127 -0
- package/templates/base/src/components/theme-provider.tsx +58 -0
|
@@ -6,10 +6,36 @@ import { ChatMessage } from "./chat-message";
|
|
|
6
6
|
import { ChatInput } from "./chat-input";
|
|
7
7
|
import { ChatSidebar } from "./chat-sidebar";
|
|
8
8
|
import { useRef, useEffect, useMemo, useState, useCallback } from "react";
|
|
9
|
-
import { ArrowDown } from "lucide-react";
|
|
9
|
+
import { ArrowDown, Mail, MessageSquare, Github, ExternalLink } from "lucide-react";
|
|
10
|
+
import { ContextIndicator } from "@supyagent/sdk/react";
|
|
10
11
|
import { Header } from "./header";
|
|
11
12
|
import Image from "next/image";
|
|
12
13
|
|
|
14
|
+
interface Integration {
|
|
15
|
+
provider: string;
|
|
16
|
+
status: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface MeData {
|
|
20
|
+
integrations: Integration[];
|
|
21
|
+
dashboardUrl: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PROVIDER_ICONS: Record<string, React.ReactNode> = {
|
|
25
|
+
google: <Mail className="h-4 w-4" />,
|
|
26
|
+
slack: <MessageSquare className="h-4 w-4" />,
|
|
27
|
+
github: <Github className="h-4 w-4" />,
|
|
28
|
+
discord: <MessageSquare className="h-4 w-4" />,
|
|
29
|
+
microsoft: <Mail className="h-4 w-4" />,
|
|
30
|
+
notion: <ExternalLink className="h-4 w-4" />,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const POPULAR_INTEGRATIONS = [
|
|
34
|
+
{ provider: "google", label: "Google", description: "Gmail, Calendar, Drive" },
|
|
35
|
+
{ provider: "slack", label: "Slack", description: "Messages & channels" },
|
|
36
|
+
{ provider: "github", label: "GitHub", description: "Repos & issues" },
|
|
37
|
+
];
|
|
38
|
+
|
|
13
39
|
interface ChatProps {
|
|
14
40
|
chatId: string;
|
|
15
41
|
initialMessages: UIMessage[];
|
|
@@ -25,7 +51,7 @@ export function Chat({ chatId, initialMessages }: ChatProps) {
|
|
|
25
51
|
[chatId]
|
|
26
52
|
);
|
|
27
53
|
|
|
28
|
-
const { messages, sendMessage, status, stop, addToolApprovalResponse } = useChat({
|
|
54
|
+
const { messages, sendMessage, status, stop, addToolApprovalResponse, setMessages } = useChat({
|
|
29
55
|
id: chatId,
|
|
30
56
|
transport,
|
|
31
57
|
messages: initialMessages,
|
|
@@ -33,10 +59,41 @@ export function Chat({ chatId, initialMessages }: ChatProps) {
|
|
|
33
59
|
});
|
|
34
60
|
|
|
35
61
|
const isLoading = status === "submitted" || status === "streaming";
|
|
62
|
+
const [isCompacting, setIsCompacting] = useState(false);
|
|
63
|
+
|
|
64
|
+
const handleCompact = useCallback(async () => {
|
|
65
|
+
if (isCompacting || isLoading) return;
|
|
66
|
+
setIsCompacting(true);
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch("/api/chat/compact", {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
body: JSON.stringify({ chatId }),
|
|
72
|
+
});
|
|
73
|
+
if (res.ok) {
|
|
74
|
+
const { messages: compactedMessages } = await res.json();
|
|
75
|
+
setMessages(compactedMessages);
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Compact failed silently
|
|
79
|
+
} finally {
|
|
80
|
+
setIsCompacting(false);
|
|
81
|
+
}
|
|
82
|
+
}, [chatId, isCompacting, isLoading, setMessages]);
|
|
36
83
|
|
|
37
84
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
38
85
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
39
86
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
87
|
+
const [meData, setMeData] = useState<MeData | null>(null);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
fetch("/api/me")
|
|
91
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
92
|
+
.then((data) => {
|
|
93
|
+
if (data) setMeData(data);
|
|
94
|
+
})
|
|
95
|
+
.catch(() => {});
|
|
96
|
+
}, []);
|
|
40
97
|
|
|
41
98
|
const scrollToBottom = useCallback(() => {
|
|
42
99
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
@@ -68,6 +125,9 @@ export function Chat({ chatId, initialMessages }: ChatProps) {
|
|
|
68
125
|
"What's on my calendar today?",
|
|
69
126
|
];
|
|
70
127
|
|
|
128
|
+
const hasIntegrations = meData && meData.integrations.length > 0;
|
|
129
|
+
const dashboardUrl = meData?.dashboardUrl || "https://app.supyagent.com";
|
|
130
|
+
|
|
71
131
|
return (
|
|
72
132
|
<div className="flex h-screen flex-col">
|
|
73
133
|
<Header />
|
|
@@ -82,29 +142,106 @@ export function Chat({ chatId, initialMessages }: ChatProps) {
|
|
|
82
142
|
<div className="mx-auto max-w-3xl space-y-6">
|
|
83
143
|
{messages.length === 0 && (
|
|
84
144
|
<div className="flex h-full min-h-[60vh] items-center justify-center">
|
|
85
|
-
|
|
86
|
-
<div className="
|
|
87
|
-
<
|
|
145
|
+
{!hasIntegrations && meData !== null ? (
|
|
146
|
+
<div className="text-center max-w-lg">
|
|
147
|
+
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-muted">
|
|
148
|
+
<Image src="/logo.png" alt="Supyagent" width={24} height={24} className="opacity-70" />
|
|
149
|
+
</div>
|
|
150
|
+
<h1 className="text-xl font-semibold text-foreground">
|
|
151
|
+
Get started
|
|
152
|
+
</h1>
|
|
153
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
154
|
+
Connect your integrations to start using your AI agent.
|
|
155
|
+
</p>
|
|
156
|
+
|
|
157
|
+
<div className="mt-6 space-y-2">
|
|
158
|
+
<div className="flex items-center gap-3 rounded-lg border border-border bg-card p-3 text-left">
|
|
159
|
+
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">1</span>
|
|
160
|
+
<div className="flex-1 min-w-0">
|
|
161
|
+
<p className="text-sm font-medium text-foreground">Connect integrations</p>
|
|
162
|
+
<p className="text-xs text-muted-foreground">Link your services to give your agent access</p>
|
|
163
|
+
</div>
|
|
164
|
+
<a
|
|
165
|
+
href={`${dashboardUrl}/integrations`}
|
|
166
|
+
target="_blank"
|
|
167
|
+
rel="noopener noreferrer"
|
|
168
|
+
className="shrink-0 rounded-md border border-border px-3 py-1 text-xs font-medium text-foreground hover:bg-muted transition-colors"
|
|
169
|
+
>
|
|
170
|
+
Open dashboard
|
|
171
|
+
</a>
|
|
172
|
+
</div>
|
|
173
|
+
<div className="flex items-center gap-3 rounded-lg border border-border bg-card p-3 text-left">
|
|
174
|
+
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">2</span>
|
|
175
|
+
<div className="flex-1 min-w-0">
|
|
176
|
+
<p className="text-sm font-medium text-foreground">Try a command</p>
|
|
177
|
+
<p className="text-xs text-muted-foreground">Type <kbd className="rounded bg-muted px-1 py-0.5 text-[10px] font-mono">/</kbd> to see quick actions</p>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div className="mt-6">
|
|
183
|
+
<p className="mb-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Popular integrations</p>
|
|
184
|
+
<div className="flex justify-center gap-3">
|
|
185
|
+
{POPULAR_INTEGRATIONS.map((integration) => (
|
|
186
|
+
<a
|
|
187
|
+
key={integration.provider}
|
|
188
|
+
href={`${dashboardUrl}/integrations`}
|
|
189
|
+
target="_blank"
|
|
190
|
+
rel="noopener noreferrer"
|
|
191
|
+
className="flex flex-col items-center gap-1.5 rounded-lg border border-border bg-card p-3 hover:bg-muted transition-colors w-28"
|
|
192
|
+
>
|
|
193
|
+
<span className="text-muted-foreground">
|
|
194
|
+
{PROVIDER_ICONS[integration.provider]}
|
|
195
|
+
</span>
|
|
196
|
+
<span className="text-xs font-medium text-foreground">{integration.label}</span>
|
|
197
|
+
<span className="text-[10px] text-muted-foreground">{integration.description}</span>
|
|
198
|
+
</a>
|
|
199
|
+
))}
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
88
202
|
</div>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
>
|
|
103
|
-
{
|
|
104
|
-
|
|
105
|
-
|
|
203
|
+
) : (
|
|
204
|
+
<div className="text-center max-w-md">
|
|
205
|
+
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-muted">
|
|
206
|
+
<Image src="/logo.png" alt="Supyagent" width={24} height={24} className="opacity-70" />
|
|
207
|
+
</div>
|
|
208
|
+
<h1 className="text-xl font-semibold text-foreground">
|
|
209
|
+
Supyagent Chat
|
|
210
|
+
</h1>
|
|
211
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
212
|
+
Ask me anything — I can use your connected integrations.
|
|
213
|
+
</p>
|
|
214
|
+
|
|
215
|
+
{hasIntegrations && (
|
|
216
|
+
<div className="mt-4 flex justify-center gap-1.5">
|
|
217
|
+
{meData.integrations.map((integration) => (
|
|
218
|
+
<span
|
|
219
|
+
key={integration.provider}
|
|
220
|
+
className="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground"
|
|
221
|
+
title={integration.provider}
|
|
222
|
+
>
|
|
223
|
+
{PROVIDER_ICONS[integration.provider] || (
|
|
224
|
+
<span className="text-[10px] font-medium uppercase">{integration.provider.slice(0, 2)}</span>
|
|
225
|
+
)}
|
|
226
|
+
</span>
|
|
227
|
+
))}
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
|
|
231
|
+
<div className="mt-6 flex flex-wrap justify-center gap-2">
|
|
232
|
+
{suggestions.map((suggestion) => (
|
|
233
|
+
<button
|
|
234
|
+
key={suggestion}
|
|
235
|
+
type="button"
|
|
236
|
+
onClick={() => sendMessage({ text: suggestion })}
|
|
237
|
+
className="rounded-full border border-border bg-card px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
|
238
|
+
>
|
|
239
|
+
{suggestion}
|
|
240
|
+
</button>
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
106
243
|
</div>
|
|
107
|
-
|
|
244
|
+
)}
|
|
108
245
|
</div>
|
|
109
246
|
)}
|
|
110
247
|
{messages.map((message) => (
|
|
@@ -128,10 +265,14 @@ export function Chat({ chatId, initialMessages }: ChatProps) {
|
|
|
128
265
|
|
|
129
266
|
<div className="border-t border-border px-4 py-4">
|
|
130
267
|
<div className="mx-auto max-w-3xl">
|
|
268
|
+
<div className="flex items-center justify-end mb-1.5 min-h-[20px]">
|
|
269
|
+
<ContextIndicator messages={messages} />
|
|
270
|
+
</div>
|
|
131
271
|
<ChatInput
|
|
132
272
|
sendMessage={sendMessage}
|
|
133
273
|
isLoading={isLoading}
|
|
134
274
|
stop={stop}
|
|
275
|
+
onCompact={handleCompact}
|
|
135
276
|
/>
|
|
136
277
|
</div>
|
|
137
278
|
</div>
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import Image from "next/image";
|
|
4
|
+
import { Sun, Moon } from "lucide-react";
|
|
4
5
|
import { UserButton } from "./user-button";
|
|
6
|
+
import { useTheme } from "./theme-provider";
|
|
5
7
|
|
|
6
8
|
export function Header() {
|
|
9
|
+
const { theme, toggleTheme } = useTheme();
|
|
10
|
+
|
|
7
11
|
return (
|
|
8
12
|
<header className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-card px-4">
|
|
9
13
|
<div className="flex items-center gap-2.5">
|
|
@@ -16,7 +20,17 @@ export function Header() {
|
|
|
16
20
|
/>
|
|
17
21
|
<span className="text-sm font-medium text-foreground">Supyagent</span>
|
|
18
22
|
</div>
|
|
19
|
-
<
|
|
23
|
+
<div className="flex items-center gap-2">
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
onClick={toggleTheme}
|
|
27
|
+
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
|
28
|
+
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
|
29
|
+
>
|
|
30
|
+
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
|
31
|
+
</button>
|
|
32
|
+
<UserButton />
|
|
33
|
+
</div>
|
|
20
34
|
</header>
|
|
21
35
|
);
|
|
22
36
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, useCallback } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Mail,
|
|
6
|
+
Calendar,
|
|
7
|
+
HardDrive,
|
|
8
|
+
MessageSquare,
|
|
9
|
+
Github,
|
|
10
|
+
Search,
|
|
11
|
+
Send,
|
|
12
|
+
CalendarClock,
|
|
13
|
+
Minimize2,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
|
|
16
|
+
interface SlashCommand {
|
|
17
|
+
name: string;
|
|
18
|
+
label: string;
|
|
19
|
+
description: string;
|
|
20
|
+
prompt: string;
|
|
21
|
+
icon: React.ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const COMMANDS: SlashCommand[] = [
|
|
25
|
+
{ name: "email", label: "/email", description: "Search my recent emails", prompt: "Search my recent emails", icon: <Mail className="h-4 w-4" /> },
|
|
26
|
+
{ name: "calendar", label: "/calendar", description: "What's on my calendar today?", prompt: "What's on my calendar today?", icon: <Calendar className="h-4 w-4" /> },
|
|
27
|
+
{ name: "drive", label: "/drive", description: "Find files in my Drive", prompt: "Find files in my Drive", icon: <HardDrive className="h-4 w-4" /> },
|
|
28
|
+
{ name: "slack", label: "/slack", description: "Check my Slack messages", prompt: "Check my Slack messages", icon: <MessageSquare className="h-4 w-4" /> },
|
|
29
|
+
{ name: "github", label: "/github", description: "Show my recent GitHub activity", prompt: "Show my recent GitHub activity", icon: <Github className="h-4 w-4" /> },
|
|
30
|
+
{ name: "search", label: "/search", description: "Search the web for...", prompt: "Search the web for ", icon: <Search className="h-4 w-4" /> },
|
|
31
|
+
{ name: "compose", label: "/compose", description: "Draft an email to...", prompt: "Draft an email to ", icon: <Send className="h-4 w-4" /> },
|
|
32
|
+
{ name: "schedule", label: "/schedule", description: "Schedule a meeting for...", prompt: "Schedule a meeting for ", icon: <CalendarClock className="h-4 w-4" /> },
|
|
33
|
+
{ name: "compact", label: "/compact", description: "Compact conversation history", prompt: "__compact__", icon: <Minimize2 className="h-4 w-4" /> },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
interface SlashMenuProps {
|
|
37
|
+
query: string;
|
|
38
|
+
onSelect: (command: SlashCommand) => void;
|
|
39
|
+
onClose: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function SlashMenu({ query, onSelect, onClose }: SlashMenuProps) {
|
|
43
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
44
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
45
|
+
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
46
|
+
|
|
47
|
+
const filtered = COMMANDS.filter(
|
|
48
|
+
(cmd) =>
|
|
49
|
+
cmd.name.startsWith(query.toLowerCase()) ||
|
|
50
|
+
cmd.description.toLowerCase().includes(query.toLowerCase())
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Reset selection when filtered list changes
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
setSelectedIndex(0);
|
|
56
|
+
}, [query]);
|
|
57
|
+
|
|
58
|
+
// Scroll selected item into view
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
|
|
61
|
+
}, [selectedIndex]);
|
|
62
|
+
|
|
63
|
+
const handleKeyDown = useCallback(
|
|
64
|
+
(e: KeyboardEvent) => {
|
|
65
|
+
if (filtered.length === 0) return;
|
|
66
|
+
|
|
67
|
+
switch (e.key) {
|
|
68
|
+
case "ArrowDown":
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
setSelectedIndex((prev) => (prev + 1) % filtered.length);
|
|
71
|
+
break;
|
|
72
|
+
case "ArrowUp":
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
setSelectedIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
|
|
75
|
+
break;
|
|
76
|
+
case "Enter":
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
onSelect(filtered[selectedIndex]);
|
|
79
|
+
break;
|
|
80
|
+
case "Escape":
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
onClose();
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
[filtered, selectedIndex, onSelect, onClose]
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
91
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
92
|
+
}, [handleKeyDown]);
|
|
93
|
+
|
|
94
|
+
if (filtered.length === 0) return null;
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
ref={menuRef}
|
|
99
|
+
className="absolute bottom-full left-0 right-0 mb-2 max-h-64 overflow-y-auto rounded-xl border border-border bg-card shadow-lg"
|
|
100
|
+
>
|
|
101
|
+
<div className="p-1">
|
|
102
|
+
{filtered.map((cmd, i) => (
|
|
103
|
+
<button
|
|
104
|
+
key={cmd.name}
|
|
105
|
+
ref={(el) => { itemRefs.current[i] = el; }}
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => onSelect(cmd)}
|
|
108
|
+
onMouseEnter={() => setSelectedIndex(i)}
|
|
109
|
+
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors ${
|
|
110
|
+
i === selectedIndex
|
|
111
|
+
? "bg-muted text-foreground"
|
|
112
|
+
: "text-muted-foreground hover:bg-muted/50"
|
|
113
|
+
}`}
|
|
114
|
+
>
|
|
115
|
+
<span className="shrink-0 text-muted-foreground">{cmd.icon}</span>
|
|
116
|
+
<div className="flex-1 min-w-0">
|
|
117
|
+
<span className="text-sm font-medium">{cmd.label}</span>
|
|
118
|
+
<span className="ml-2 text-xs text-muted-foreground">{cmd.description}</span>
|
|
119
|
+
</div>
|
|
120
|
+
</button>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type { SlashCommand };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
type Theme = "light" | "dark";
|
|
6
|
+
|
|
7
|
+
interface ThemeContextValue {
|
|
8
|
+
theme: Theme;
|
|
9
|
+
toggleTheme: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
|
13
|
+
|
|
14
|
+
function getStoredTheme(): Theme {
|
|
15
|
+
if (typeof window === "undefined") return "dark";
|
|
16
|
+
const stored = localStorage.getItem("theme");
|
|
17
|
+
return stored === "light" || stored === "dark" ? stored : "dark";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function applyTheme(theme: Theme) {
|
|
21
|
+
const root = document.documentElement;
|
|
22
|
+
if (theme === "dark") {
|
|
23
|
+
root.classList.add("dark");
|
|
24
|
+
} else {
|
|
25
|
+
root.classList.remove("dark");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
30
|
+
const [theme, setTheme] = useState<Theme>("dark");
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const initial = getStoredTheme();
|
|
34
|
+
setTheme(initial);
|
|
35
|
+
applyTheme(initial);
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const toggleTheme = useCallback(() => {
|
|
39
|
+
setTheme((prev) => {
|
|
40
|
+
const next = prev === "dark" ? "light" : "dark";
|
|
41
|
+
localStorage.setItem("theme", next);
|
|
42
|
+
applyTheme(next);
|
|
43
|
+
return next;
|
|
44
|
+
});
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
|
49
|
+
{children}
|
|
50
|
+
</ThemeContext.Provider>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useTheme(): ThemeContextValue {
|
|
55
|
+
const ctx = useContext(ThemeContext);
|
|
56
|
+
if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
|
|
57
|
+
return ctx;
|
|
58
|
+
}
|