@sansavision/create-pulse 0.4.1 → 0.4.2
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/README.md +27 -3
- package/dist/index.js +3 -1
- package/package.json +1 -1
- package/templates/nextjs-auth-demo/.env.example +6 -0
- package/templates/nextjs-auth-demo/README.md +74 -0
- package/templates/nextjs-auth-demo/_gitignore +33 -0
- package/templates/nextjs-auth-demo/drizzle.config.ts +10 -0
- package/templates/nextjs-auth-demo/eslint.config.mjs +18 -0
- package/templates/nextjs-auth-demo/next-env.d.ts +6 -0
- package/templates/nextjs-auth-demo/next.config.ts +7 -0
- package/templates/nextjs-auth-demo/package.json +34 -0
- package/templates/nextjs-auth-demo/postcss.config.mjs +7 -0
- package/templates/nextjs-auth-demo/public/file.svg +1 -0
- package/templates/nextjs-auth-demo/public/globe.svg +1 -0
- package/templates/nextjs-auth-demo/public/next.svg +1 -0
- package/templates/nextjs-auth-demo/public/vercel.svg +1 -0
- package/templates/nextjs-auth-demo/public/window.svg +1 -0
- package/templates/nextjs-auth-demo/src/app/api/auth/[...all]/route.ts +4 -0
- package/templates/nextjs-auth-demo/src/app/api/pulse/verify/route.ts +54 -0
- package/templates/nextjs-auth-demo/src/app/auth/sign-in/page.tsx +131 -0
- package/templates/nextjs-auth-demo/src/app/auth/sign-up/page.tsx +153 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/chat/page.tsx +248 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +198 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/game-sync/page.tsx +192 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/queues/page.tsx +297 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/watch-together/page.tsx +258 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/layout.tsx +109 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/page.tsx +147 -0
- package/templates/nextjs-auth-demo/src/app/favicon.ico +0 -0
- package/templates/nextjs-auth-demo/src/app/globals.css +96 -0
- package/templates/nextjs-auth-demo/src/app/layout.tsx +27 -0
- package/templates/nextjs-auth-demo/src/app/page.tsx +254 -0
- package/templates/nextjs-auth-demo/src/lib/auth-client.ts +15 -0
- package/templates/nextjs-auth-demo/src/lib/auth.ts +13 -0
- package/templates/nextjs-auth-demo/src/lib/db.ts +5 -0
- package/templates/nextjs-auth-demo/src/lib/pulse.ts +45 -0
- package/templates/nextjs-auth-demo/tsconfig.json +34 -0
- package/templates/react-queue-demo/README.md +6 -7
- package/templates/react-queue-demo/src/App.tsx +34 -13
- package/src/index.ts +0 -115
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
4
|
+
import { useSession } from "@/lib/auth-client";
|
|
5
|
+
import { connectWithAuth } from "@/lib/pulse";
|
|
6
|
+
import {
|
|
7
|
+
Send,
|
|
8
|
+
Users,
|
|
9
|
+
Wifi,
|
|
10
|
+
WifiOff,
|
|
11
|
+
Shield,
|
|
12
|
+
Loader2,
|
|
13
|
+
MessageSquare,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import type { PulseConnection } from "@sansavision/pulse-sdk";
|
|
16
|
+
|
|
17
|
+
interface ChatMessage {
|
|
18
|
+
id: string;
|
|
19
|
+
user: string;
|
|
20
|
+
userId: string;
|
|
21
|
+
text: string;
|
|
22
|
+
timestamp: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function ChatDemoPage() {
|
|
26
|
+
const { data: session } = useSession();
|
|
27
|
+
const [connected, setConnected] = useState(false);
|
|
28
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
29
|
+
const [input, setInput] = useState("");
|
|
30
|
+
const [authUser, setAuthUser] = useState<{
|
|
31
|
+
id: string;
|
|
32
|
+
claims: Record<string, string>;
|
|
33
|
+
} | null>(null);
|
|
34
|
+
const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
|
|
35
|
+
const [connecting, setConnecting] = useState(true);
|
|
36
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
37
|
+
const connRef = useRef<PulseConnection | null>(null);
|
|
38
|
+
|
|
39
|
+
const scrollToBottom = useCallback(() => {
|
|
40
|
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!session) return;
|
|
45
|
+
let cancelled = false;
|
|
46
|
+
|
|
47
|
+
async function init() {
|
|
48
|
+
try {
|
|
49
|
+
const connection = await connectWithAuth();
|
|
50
|
+
if (cancelled) return;
|
|
51
|
+
|
|
52
|
+
connRef.current = connection;
|
|
53
|
+
setConnected(true);
|
|
54
|
+
setConnecting(false);
|
|
55
|
+
|
|
56
|
+
// Check auth user from the ACCEPT payload
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
58
|
+
const user = (connection as any).user;
|
|
59
|
+
if (user) setAuthUser(user);
|
|
60
|
+
|
|
61
|
+
// Open a chat stream
|
|
62
|
+
const stream = connection.stream("chat-room");
|
|
63
|
+
|
|
64
|
+
// Announce join
|
|
65
|
+
stream.send(
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
type: "join",
|
|
68
|
+
user: session!.user.name,
|
|
69
|
+
userId: session!.user.id,
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Listen for messages
|
|
74
|
+
stream.on("data", (data: Uint8Array) => {
|
|
75
|
+
try {
|
|
76
|
+
const msg = JSON.parse(new TextDecoder().decode(data));
|
|
77
|
+
if (msg.type === "chat") {
|
|
78
|
+
setMessages((prev) => [
|
|
79
|
+
...prev,
|
|
80
|
+
{
|
|
81
|
+
id: crypto.randomUUID(),
|
|
82
|
+
user: msg.user,
|
|
83
|
+
userId: msg.userId,
|
|
84
|
+
text: msg.text,
|
|
85
|
+
timestamp: Date.now(),
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
} else if (msg.type === "join") {
|
|
89
|
+
setOnlineUsers((prev) =>
|
|
90
|
+
prev.includes(msg.user) ? prev : [...prev, msg.user]
|
|
91
|
+
);
|
|
92
|
+
setMessages((prev) => [
|
|
93
|
+
...prev,
|
|
94
|
+
{
|
|
95
|
+
id: crypto.randomUUID(),
|
|
96
|
+
user: "System",
|
|
97
|
+
userId: "system",
|
|
98
|
+
text: `${msg.user} joined the chat`,
|
|
99
|
+
timestamp: Date.now(),
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// ignore parse errors
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
connection.on("disconnect", () => setConnected(false));
|
|
109
|
+
connection.on("reconnected", () => setConnected(true));
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error("Failed to connect:", err);
|
|
112
|
+
setConnecting(false);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
init();
|
|
117
|
+
|
|
118
|
+
return () => {
|
|
119
|
+
cancelled = true;
|
|
120
|
+
connRef.current?.disconnect();
|
|
121
|
+
};
|
|
122
|
+
}, [session]);
|
|
123
|
+
|
|
124
|
+
useEffect(scrollToBottom, [messages, scrollToBottom]);
|
|
125
|
+
|
|
126
|
+
function sendMessage(e: React.FormEvent) {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
if (!input.trim() || !connRef.current) return;
|
|
129
|
+
|
|
130
|
+
const stream = connRef.current.stream("chat-room");
|
|
131
|
+
stream.send(
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
type: "chat",
|
|
134
|
+
user: session?.user?.name || "Anonymous",
|
|
135
|
+
userId: session?.user?.id || "unknown",
|
|
136
|
+
text: input.trim(),
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
setInput("");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="h-screen flex flex-col">
|
|
144
|
+
{/* Header */}
|
|
145
|
+
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
|
|
146
|
+
<div className="flex items-center gap-3">
|
|
147
|
+
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center">
|
|
148
|
+
<MessageSquare className="w-5 h-5 text-white" />
|
|
149
|
+
</div>
|
|
150
|
+
<div>
|
|
151
|
+
<h1 className="text-lg font-semibold">Real-time Chat</h1>
|
|
152
|
+
<p className="text-xs text-slate-500">
|
|
153
|
+
Multi-user authenticated messaging
|
|
154
|
+
</p>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
<div className="flex items-center gap-4">
|
|
158
|
+
{authUser && (
|
|
159
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/20">
|
|
160
|
+
<Shield className="w-3.5 h-3.5 text-green-400" />
|
|
161
|
+
<span className="text-xs text-green-400">
|
|
162
|
+
{authUser.claims.name || authUser.id}
|
|
163
|
+
</span>
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
<div className="flex items-center gap-2">
|
|
167
|
+
{connected ? (
|
|
168
|
+
<>
|
|
169
|
+
<Wifi className="w-4 h-4 text-green-400" />
|
|
170
|
+
<span className="text-xs text-green-400">Connected</span>
|
|
171
|
+
</>
|
|
172
|
+
) : (
|
|
173
|
+
<>
|
|
174
|
+
<WifiOff className="w-4 h-4 text-red-400" />
|
|
175
|
+
<span className="text-xs text-red-400">Disconnected</span>
|
|
176
|
+
</>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-800">
|
|
180
|
+
<Users className="w-3.5 h-3.5 text-slate-400" />
|
|
181
|
+
<span className="text-xs text-slate-400">
|
|
182
|
+
{onlineUsers.length || 1} online
|
|
183
|
+
</span>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{/* Messages */}
|
|
189
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
|
190
|
+
{connecting && (
|
|
191
|
+
<div className="flex items-center justify-center py-20">
|
|
192
|
+
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mr-2" />
|
|
193
|
+
<span className="text-slate-400">
|
|
194
|
+
Connecting to Pulse relay...
|
|
195
|
+
</span>
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{messages.map((msg) => (
|
|
200
|
+
<div
|
|
201
|
+
key={msg.id}
|
|
202
|
+
className={`flex ${msg.userId === session?.user?.id ? "justify-end" : "justify-start"
|
|
203
|
+
}`}
|
|
204
|
+
>
|
|
205
|
+
<div
|
|
206
|
+
className={`max-w-md px-4 py-2.5 rounded-2xl text-sm ${msg.userId === "system"
|
|
207
|
+
? "bg-slate-800/50 text-slate-500 text-center text-xs mx-auto italic"
|
|
208
|
+
: msg.userId === session?.user?.id
|
|
209
|
+
? "bg-purple-600 text-white rounded-br-md"
|
|
210
|
+
: "glass text-slate-200 rounded-bl-md"
|
|
211
|
+
}`}
|
|
212
|
+
>
|
|
213
|
+
{msg.userId !== "system" && msg.userId !== session?.user?.id && (
|
|
214
|
+
<div className="text-xs text-purple-400 font-medium mb-1">
|
|
215
|
+
{msg.user}
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
{msg.text}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
))}
|
|
222
|
+
<div ref={messagesEndRef} />
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{/* Input */}
|
|
226
|
+
<form
|
|
227
|
+
onSubmit={sendMessage}
|
|
228
|
+
className="p-4 border-t border-slate-800 flex gap-3"
|
|
229
|
+
>
|
|
230
|
+
<input
|
|
231
|
+
type="text"
|
|
232
|
+
value={input}
|
|
233
|
+
onChange={(e) => setInput(e.target.value)}
|
|
234
|
+
placeholder="Type a message..."
|
|
235
|
+
disabled={!connected}
|
|
236
|
+
className="flex-1 px-4 py-2.5 rounded-xl bg-slate-800/50 border border-slate-700 focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none text-sm transition-colors placeholder:text-slate-600 disabled:opacity-50"
|
|
237
|
+
/>
|
|
238
|
+
<button
|
|
239
|
+
type="submit"
|
|
240
|
+
disabled={!connected || !input.trim()}
|
|
241
|
+
className="px-4 py-2.5 bg-purple-600 hover:bg-purple-500 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
242
|
+
>
|
|
243
|
+
<Send className="w-4 h-4" />
|
|
244
|
+
</button>
|
|
245
|
+
</form>
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
4
|
+
import { useSession } from "@/lib/auth-client";
|
|
5
|
+
import { connectWithAuth } from "@/lib/pulse";
|
|
6
|
+
import { Lock, Send, Shield, Wifi, WifiOff, Loader2, Key, ShieldCheck, Eye, EyeOff } from "lucide-react";
|
|
7
|
+
import type { PulseConnection } from "@sansavision/pulse-sdk";
|
|
8
|
+
|
|
9
|
+
interface EncryptedMessage {
|
|
10
|
+
id: string;
|
|
11
|
+
sender: string;
|
|
12
|
+
plaintext: string;
|
|
13
|
+
ciphertext: string;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function EncryptedChatPage() {
|
|
18
|
+
const { data: session } = useSession();
|
|
19
|
+
const [connected, setConnected] = useState(false);
|
|
20
|
+
const [authUser, setAuthUser] = useState<{ id: string; claims: Record<string, string> } | null>(null);
|
|
21
|
+
const [connecting, setConnecting] = useState(true);
|
|
22
|
+
const [messages, setMessages] = useState<EncryptedMessage[]>([]);
|
|
23
|
+
const [input, setInput] = useState("");
|
|
24
|
+
const [showCiphertext, setShowCiphertext] = useState(false);
|
|
25
|
+
const [encryptionKey] = useState(() =>
|
|
26
|
+
Array.from(crypto.getRandomValues(new Uint8Array(16)))
|
|
27
|
+
.map((b) => b.toString(16).padStart(2, "0")).join("")
|
|
28
|
+
);
|
|
29
|
+
const connRef = useRef<PulseConnection | null>(null);
|
|
30
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
|
|
32
|
+
const scrollToBottom = useCallback(() => {
|
|
33
|
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
function simpleEncrypt(text: string, key: string): string {
|
|
37
|
+
return Array.from(text).map((c, i) =>
|
|
38
|
+
(c.charCodeAt(0) ^ key.charCodeAt(i % key.length)).toString(16).padStart(2, "0")
|
|
39
|
+
).join("");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function simpleDecrypt(hex: string, key: string): string {
|
|
43
|
+
const bytes = hex.match(/.{2}/g) || [];
|
|
44
|
+
return bytes.map((b, i) =>
|
|
45
|
+
String.fromCharCode(parseInt(b, 16) ^ key.charCodeAt(i % key.length))
|
|
46
|
+
).join("");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!session) return;
|
|
51
|
+
let cancelled = false;
|
|
52
|
+
|
|
53
|
+
async function init() {
|
|
54
|
+
try {
|
|
55
|
+
const connection = await connectWithAuth();
|
|
56
|
+
if (cancelled) return;
|
|
57
|
+
|
|
58
|
+
connRef.current = connection;
|
|
59
|
+
setConnected(true);
|
|
60
|
+
setConnecting(false);
|
|
61
|
+
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
63
|
+
const user = (connection as any).user;
|
|
64
|
+
if (user) setAuthUser(user);
|
|
65
|
+
|
|
66
|
+
const stream = connection.stream("encrypted-room");
|
|
67
|
+
|
|
68
|
+
stream.on("data", (data: Uint8Array) => {
|
|
69
|
+
try {
|
|
70
|
+
const msg = JSON.parse(new TextDecoder().decode(data));
|
|
71
|
+
if (msg.type === "encrypted-msg") {
|
|
72
|
+
const decrypted = simpleDecrypt(msg.ciphertext, encryptionKey);
|
|
73
|
+
setMessages((prev) => [
|
|
74
|
+
...prev,
|
|
75
|
+
{
|
|
76
|
+
id: crypto.randomUUID(),
|
|
77
|
+
sender: msg.sender,
|
|
78
|
+
plaintext: decrypted,
|
|
79
|
+
ciphertext: msg.ciphertext,
|
|
80
|
+
timestamp: Date.now(),
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
} catch { /* ignore */ }
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
connection.on("disconnect", () => setConnected(false));
|
|
88
|
+
connection.on("reconnected", () => setConnected(true));
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error("Failed to connect:", err);
|
|
91
|
+
setConnecting(false);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
init();
|
|
96
|
+
return () => {
|
|
97
|
+
cancelled = true;
|
|
98
|
+
connRef.current?.disconnect();
|
|
99
|
+
};
|
|
100
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
101
|
+
}, [session]);
|
|
102
|
+
|
|
103
|
+
useEffect(scrollToBottom, [messages, scrollToBottom]);
|
|
104
|
+
|
|
105
|
+
function sendMessage(e: React.FormEvent) {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
if (!input.trim() || !connRef.current) return;
|
|
108
|
+
|
|
109
|
+
const plaintext = input.trim();
|
|
110
|
+
const ciphertext = simpleEncrypt(plaintext, encryptionKey);
|
|
111
|
+
|
|
112
|
+
const stream = connRef.current.stream("encrypted-room");
|
|
113
|
+
stream.send(JSON.stringify({ type: "encrypted-msg", sender: session?.user?.name || "Anonymous", ciphertext }));
|
|
114
|
+
setInput("");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="h-screen flex flex-col">
|
|
119
|
+
<div className="p-4 border-b border-slate-800">
|
|
120
|
+
<div className="flex items-center justify-between mb-3">
|
|
121
|
+
<div className="flex items-center gap-3">
|
|
122
|
+
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-violet-600 flex items-center justify-center">
|
|
123
|
+
<Lock className="w-5 h-5 text-white" />
|
|
124
|
+
</div>
|
|
125
|
+
<div>
|
|
126
|
+
<h1 className="text-lg font-semibold">E2E Encrypted Chat</h1>
|
|
127
|
+
<p className="text-xs text-slate-500">Messages encrypted before leaving your browser</p>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="flex items-center gap-3">
|
|
131
|
+
{authUser && (
|
|
132
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/20">
|
|
133
|
+
<Shield className="w-3.5 h-3.5 text-green-400" />
|
|
134
|
+
<span className="text-xs text-green-400">{authUser.claims.name || authUser.id}</span>
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
{connected ? (
|
|
138
|
+
<div className="flex items-center gap-1.5"><Wifi className="w-4 h-4 text-green-400" /><span className="text-xs text-green-400">Connected</span></div>
|
|
139
|
+
) : (
|
|
140
|
+
<div className="flex items-center gap-1.5"><WifiOff className="w-4 h-4 text-red-400" /><span className="text-xs text-red-400">Disconnected</span></div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div className="flex items-center gap-4">
|
|
146
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-purple-500/10 border border-purple-500/20">
|
|
147
|
+
<Key className="w-3.5 h-3.5 text-purple-400" />
|
|
148
|
+
<code className="text-xs text-purple-400 font-mono">{encryptionKey.slice(0, 16)}...</code>
|
|
149
|
+
</div>
|
|
150
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-violet-500/10 border border-violet-500/20">
|
|
151
|
+
<ShieldCheck className="w-3.5 h-3.5 text-violet-400" />
|
|
152
|
+
<span className="text-xs text-violet-400">XOR cipher (demo)</span>
|
|
153
|
+
</div>
|
|
154
|
+
<button onClick={() => setShowCiphertext(!showCiphertext)}
|
|
155
|
+
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-800 border border-slate-700 hover:border-slate-600 transition-colors">
|
|
156
|
+
{showCiphertext ? <EyeOff className="w-3.5 h-3.5 text-slate-400" /> : <Eye className="w-3.5 h-3.5 text-slate-400" />}
|
|
157
|
+
<span className="text-xs text-slate-400">{showCiphertext ? "Hide" : "Show"} ciphertext</span>
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
|
163
|
+
{connecting && (
|
|
164
|
+
<div className="flex items-center justify-center py-20">
|
|
165
|
+
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mr-2" /><span className="text-slate-400">Establishing encrypted channel...</span>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{messages.map((msg) => (
|
|
170
|
+
<div key={msg.id}>
|
|
171
|
+
<div className={`flex ${msg.sender === session?.user?.name ? "justify-end" : "justify-start"}`}>
|
|
172
|
+
<div className={`max-w-md px-4 py-2.5 rounded-2xl text-sm ${msg.sender === session?.user?.name ? "bg-purple-600 text-white rounded-br-md" : "glass text-slate-200 rounded-bl-md"}`}>
|
|
173
|
+
{msg.sender !== session?.user?.name && <div className="text-xs text-purple-400 font-medium mb-1">{msg.sender}</div>}
|
|
174
|
+
<div className="flex items-center gap-1.5"><Lock className="w-3 h-3 text-green-400 shrink-0" />{msg.plaintext}</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
{showCiphertext && (
|
|
178
|
+
<div className={`mt-1 text-xs font-mono text-slate-600 ${msg.sender === session?.user?.name ? "text-right" : "text-left"} px-4`}>
|
|
179
|
+
🔒 {msg.ciphertext.slice(0, 40)}...
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
))}
|
|
184
|
+
<div ref={messagesEndRef} />
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<form onSubmit={sendMessage} className="p-4 border-t border-slate-800 flex gap-3">
|
|
188
|
+
<input type="text" value={input} onChange={(e) => setInput(e.target.value)} placeholder="Type an encrypted message..."
|
|
189
|
+
disabled={!connected}
|
|
190
|
+
className="flex-1 px-4 py-2.5 rounded-xl bg-slate-800/50 border border-slate-700 focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none text-sm transition-colors placeholder:text-slate-600 disabled:opacity-50" />
|
|
191
|
+
<button type="submit" disabled={!connected || !input.trim()}
|
|
192
|
+
className="px-4 py-2.5 bg-purple-600 hover:bg-purple-500 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed">
|
|
193
|
+
<Send className="w-4 h-4" />
|
|
194
|
+
</button>
|
|
195
|
+
</form>
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
4
|
+
import { useSession } from "@/lib/auth-client";
|
|
5
|
+
import { connectWithAuth } from "@/lib/pulse";
|
|
6
|
+
import { Gamepad2, Shield, Wifi, WifiOff, Loader2, RotateCcw } from "lucide-react";
|
|
7
|
+
import type { PulseConnection } from "@sansavision/pulse-sdk";
|
|
8
|
+
|
|
9
|
+
type Cell = "X" | "O" | null;
|
|
10
|
+
|
|
11
|
+
export default function GameSyncPage() {
|
|
12
|
+
const { data: session } = useSession();
|
|
13
|
+
const [connected, setConnected] = useState(false);
|
|
14
|
+
const [authUser, setAuthUser] = useState<{ id: string; claims: Record<string, string> } | null>(null);
|
|
15
|
+
const [connecting, setConnecting] = useState(true);
|
|
16
|
+
const [board, setBoard] = useState<Cell[]>(Array(9).fill(null));
|
|
17
|
+
const [currentTurn, setCurrentTurn] = useState<"X" | "O">("X");
|
|
18
|
+
const [mySymbol, setMySymbol] = useState<"X" | "O">("X");
|
|
19
|
+
const [winner, setWinner] = useState<string | null>(null);
|
|
20
|
+
const [players, setPlayers] = useState<string[]>([]);
|
|
21
|
+
const connRef = useRef<PulseConnection | null>(null);
|
|
22
|
+
|
|
23
|
+
const checkWinner = useCallback((b: Cell[]): Cell => {
|
|
24
|
+
const lines = [
|
|
25
|
+
[0, 1, 2], [3, 4, 5], [6, 7, 8],
|
|
26
|
+
[0, 3, 6], [1, 4, 7], [2, 5, 8],
|
|
27
|
+
[0, 4, 8], [2, 4, 6],
|
|
28
|
+
];
|
|
29
|
+
for (const [a, c, d] of lines) {
|
|
30
|
+
if (b[a] && b[a] === b[c] && b[a] === b[d]) return b[a];
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!session) return;
|
|
37
|
+
let cancelled = false;
|
|
38
|
+
|
|
39
|
+
async function init() {
|
|
40
|
+
try {
|
|
41
|
+
const connection = await connectWithAuth();
|
|
42
|
+
if (cancelled) return;
|
|
43
|
+
|
|
44
|
+
connRef.current = connection;
|
|
45
|
+
setConnected(true);
|
|
46
|
+
setConnecting(false);
|
|
47
|
+
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
49
|
+
const user = (connection as any).user;
|
|
50
|
+
if (user) setAuthUser(user);
|
|
51
|
+
|
|
52
|
+
const stream = connection.stream("game-room");
|
|
53
|
+
|
|
54
|
+
stream.send(JSON.stringify({
|
|
55
|
+
type: "join",
|
|
56
|
+
name: session!.user.name,
|
|
57
|
+
userId: session!.user.id,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
stream.on("data", (data: Uint8Array) => {
|
|
61
|
+
try {
|
|
62
|
+
const msg = JSON.parse(new TextDecoder().decode(data));
|
|
63
|
+
if (msg.type === "move") {
|
|
64
|
+
setBoard((prev) => {
|
|
65
|
+
const next = [...prev];
|
|
66
|
+
next[msg.cell] = msg.symbol;
|
|
67
|
+
const w = checkWinner(next);
|
|
68
|
+
if (w) setWinner(`${msg.name} (${w}) wins!`);
|
|
69
|
+
else if (next.every((c) => c)) setWinner("Draw!");
|
|
70
|
+
return next;
|
|
71
|
+
});
|
|
72
|
+
setCurrentTurn(msg.symbol === "X" ? "O" : "X");
|
|
73
|
+
} else if (msg.type === "join") {
|
|
74
|
+
setPlayers((prev) =>
|
|
75
|
+
prev.includes(msg.name) ? prev : [...prev, msg.name]
|
|
76
|
+
);
|
|
77
|
+
if (msg.userId !== session?.user?.id) {
|
|
78
|
+
setMySymbol("X");
|
|
79
|
+
}
|
|
80
|
+
} else if (msg.type === "reset") {
|
|
81
|
+
setBoard(Array(9).fill(null));
|
|
82
|
+
setCurrentTurn("X");
|
|
83
|
+
setWinner(null);
|
|
84
|
+
}
|
|
85
|
+
} catch { /* ignore */ }
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
connection.on("disconnect", () => setConnected(false));
|
|
89
|
+
connection.on("reconnected", () => setConnected(true));
|
|
90
|
+
|
|
91
|
+
setMySymbol("X");
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.error("Failed to connect:", err);
|
|
94
|
+
setConnecting(false);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
init();
|
|
99
|
+
return () => {
|
|
100
|
+
cancelled = true;
|
|
101
|
+
connRef.current?.disconnect();
|
|
102
|
+
};
|
|
103
|
+
}, [session, checkWinner]);
|
|
104
|
+
|
|
105
|
+
function handleCellClick(index: number) {
|
|
106
|
+
if (board[index] || winner || !connRef.current) return;
|
|
107
|
+
if (currentTurn !== mySymbol) return;
|
|
108
|
+
|
|
109
|
+
const stream = connRef.current.stream("game-room");
|
|
110
|
+
stream.send(JSON.stringify({
|
|
111
|
+
type: "move",
|
|
112
|
+
cell: index,
|
|
113
|
+
symbol: mySymbol,
|
|
114
|
+
name: session?.user?.name,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function handleReset() {
|
|
119
|
+
if (!connRef.current) return;
|
|
120
|
+
const stream = connRef.current.stream("game-room");
|
|
121
|
+
stream.send(JSON.stringify({ type: "reset" }));
|
|
122
|
+
setBoard(Array(9).fill(null));
|
|
123
|
+
setCurrentTurn("X");
|
|
124
|
+
setWinner(null);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className="p-8">
|
|
129
|
+
<div className="flex items-center justify-between mb-8">
|
|
130
|
+
<div className="flex items-center gap-3">
|
|
131
|
+
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-500 to-emerald-600 flex items-center justify-center">
|
|
132
|
+
<Gamepad2 className="w-6 h-6 text-white" />
|
|
133
|
+
</div>
|
|
134
|
+
<div>
|
|
135
|
+
<h1 className="text-2xl font-bold">Game State Sync</h1>
|
|
136
|
+
<p className="text-sm text-slate-500">Real-time tic-tac-toe with state sync</p>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
<div className="flex items-center gap-3">
|
|
140
|
+
{authUser && (
|
|
141
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/20">
|
|
142
|
+
<Shield className="w-3.5 h-3.5 text-green-400" />
|
|
143
|
+
<span className="text-xs text-green-400">{authUser.claims.name || authUser.id}</span>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
{connected ? (
|
|
147
|
+
<div className="flex items-center gap-1.5"><Wifi className="w-4 h-4 text-green-400" /><span className="text-xs text-green-400">Connected</span></div>
|
|
148
|
+
) : (
|
|
149
|
+
<div className="flex items-center gap-1.5"><WifiOff className="w-4 h-4 text-red-400" /><span className="text-xs text-red-400">Disconnected</span></div>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{connecting ? (
|
|
155
|
+
<div className="flex items-center justify-center py-20">
|
|
156
|
+
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mr-2" /><span className="text-slate-400">Connecting...</span>
|
|
157
|
+
</div>
|
|
158
|
+
) : (
|
|
159
|
+
<div className="max-w-lg mx-auto">
|
|
160
|
+
<div className="glass rounded-xl p-4 mb-6 flex items-center justify-between">
|
|
161
|
+
<div className="text-sm">You are: <span className={`font-bold text-lg ${mySymbol === "X" ? "text-cyan-400" : "text-pink-400"}`}>{mySymbol}</span></div>
|
|
162
|
+
<div className="text-sm text-slate-400">
|
|
163
|
+
{winner || (<>Turn: <span className={`font-bold ${currentTurn === "X" ? "text-cyan-400" : "text-pink-400"}`}>{currentTurn}</span>{currentTurn === mySymbol && " (your turn)"}</>)}
|
|
164
|
+
</div>
|
|
165
|
+
<div className="text-sm text-slate-400">{Math.max(players.length, 1)} player(s)</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div className="grid grid-cols-3 gap-3 mb-6">
|
|
169
|
+
{board.map((cell, i) => (
|
|
170
|
+
<button key={i} onClick={() => handleCellClick(i)}
|
|
171
|
+
disabled={!!cell || !!winner || currentTurn !== mySymbol || !connected}
|
|
172
|
+
className={`aspect-square rounded-xl text-4xl font-bold transition-all ${cell ? "glass" : "bg-slate-800/50 border border-slate-700 hover:border-purple-500 hover:bg-slate-800"
|
|
173
|
+
} ${!cell && !winner && currentTurn === mySymbol ? "cursor-pointer" : "cursor-default"} disabled:opacity-60`}>
|
|
174
|
+
<span className={cell === "X" ? "text-cyan-400" : "text-pink-400"}>{cell}</span>
|
|
175
|
+
</button>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{winner && (
|
|
180
|
+
<div className="text-center">
|
|
181
|
+
<p className="text-xl font-bold mb-4 gradient-text">{winner}</p>
|
|
182
|
+
<button onClick={handleReset}
|
|
183
|
+
className="inline-flex items-center gap-2 px-6 py-2.5 bg-purple-600 hover:bg-purple-500 rounded-lg font-medium transition-all">
|
|
184
|
+
<RotateCcw className="w-4 h-4" />Play Again
|
|
185
|
+
</button>
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|