@sansavision/create-pulse 0.4.3 → 0.4.4
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 +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/templates/nextjs-auth-demo/README.md +1 -1
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/arena-game/page.tsx +623 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/chat/page.tsx +21 -5
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +220 -7
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/game-sync/page.tsx +199 -47
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/video-call/page.tsx +740 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/watch-together/page.tsx +364 -51
- package/templates/nextjs-auth-demo/src/app/dashboard/layout.tsx +4 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/page.tsx +53 -5
- package/templates/nextjs-auth-demo/src/app/layout.tsx +1 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
4
4
|
import { useSession } from "@/lib/auth-client";
|
|
5
5
|
import { connectWithAuth } from "@/lib/pulse";
|
|
6
|
-
import { Gamepad2, Shield, Wifi, WifiOff, Loader2, RotateCcw } from "lucide-react";
|
|
6
|
+
import { Gamepad2, Shield, Wifi, WifiOff, Loader2, RotateCcw, Copy, Users, LogIn } from "lucide-react";
|
|
7
7
|
import type { PulseConnection } from "@sansavision/pulse-sdk";
|
|
8
8
|
|
|
9
9
|
type Cell = "X" | "O" | null;
|
|
@@ -15,10 +15,16 @@ export default function GameSyncPage() {
|
|
|
15
15
|
const [connecting, setConnecting] = useState(true);
|
|
16
16
|
const [board, setBoard] = useState<Cell[]>(Array(9).fill(null));
|
|
17
17
|
const [currentTurn, setCurrentTurn] = useState<"X" | "O">("X");
|
|
18
|
-
const [mySymbol, setMySymbol] = useState<"X" | "O">(
|
|
18
|
+
const [mySymbol, setMySymbol] = useState<"X" | "O" | null>(null);
|
|
19
19
|
const [winner, setWinner] = useState<string | null>(null);
|
|
20
20
|
const [players, setPlayers] = useState<string[]>([]);
|
|
21
21
|
const connRef = useRef<PulseConnection | null>(null);
|
|
22
|
+
// Room management
|
|
23
|
+
const [roomCode, setRoomCode] = useState<string>("");
|
|
24
|
+
const [joinCode, setJoinCode] = useState("");
|
|
25
|
+
const [inRoom, setInRoom] = useState(false);
|
|
26
|
+
const [copied, setCopied] = useState(false);
|
|
27
|
+
const streamNameRef = useRef<string>("");
|
|
22
28
|
|
|
23
29
|
const checkWinner = useCallback((b: Cell[]): Cell => {
|
|
24
30
|
const lines = [
|
|
@@ -32,6 +38,7 @@ export default function GameSyncPage() {
|
|
|
32
38
|
return null;
|
|
33
39
|
}, []);
|
|
34
40
|
|
|
41
|
+
// Connect to relay on mount
|
|
35
42
|
useEffect(() => {
|
|
36
43
|
if (!session) return;
|
|
37
44
|
let cancelled = false;
|
|
@@ -49,46 +56,8 @@ export default function GameSyncPage() {
|
|
|
49
56
|
const user = (connection as any).user;
|
|
50
57
|
if (user) setAuthUser(user);
|
|
51
58
|
|
|
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
59
|
connection.on("disconnect", () => setConnected(false));
|
|
89
60
|
connection.on("reconnected", () => setConnected(true));
|
|
90
|
-
|
|
91
|
-
setMySymbol("X");
|
|
92
61
|
} catch (err) {
|
|
93
62
|
console.error("Failed to connect:", err);
|
|
94
63
|
setConnecting(false);
|
|
@@ -100,28 +69,128 @@ export default function GameSyncPage() {
|
|
|
100
69
|
cancelled = true;
|
|
101
70
|
connRef.current?.disconnect();
|
|
102
71
|
};
|
|
103
|
-
}, [session
|
|
72
|
+
}, [session]);
|
|
73
|
+
|
|
74
|
+
function joinRoom(code: string) {
|
|
75
|
+
if (!connRef.current || !session) return;
|
|
76
|
+
|
|
77
|
+
const streamName = `ttt-${code}`;
|
|
78
|
+
streamNameRef.current = streamName;
|
|
79
|
+
setRoomCode(code);
|
|
80
|
+
setInRoom(true);
|
|
81
|
+
|
|
82
|
+
const stream = connRef.current.stream(streamName);
|
|
83
|
+
|
|
84
|
+
// Send join message
|
|
85
|
+
stream.send(JSON.stringify({
|
|
86
|
+
type: "join",
|
|
87
|
+
name: session.user.name,
|
|
88
|
+
userId: session.user.id,
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
// Listen for game events (skip self-echoes for moves)
|
|
92
|
+
stream.on("data", (data: Uint8Array) => {
|
|
93
|
+
try {
|
|
94
|
+
const msg = JSON.parse(new TextDecoder().decode(data));
|
|
95
|
+
if (msg.type === "move" && msg.userId !== session.user.id) {
|
|
96
|
+
setBoard((prev) => {
|
|
97
|
+
const next = [...prev];
|
|
98
|
+
next[msg.cell] = msg.symbol;
|
|
99
|
+
const w = checkWinner(next);
|
|
100
|
+
if (w) setWinner(`${msg.name} (${w}) wins!`);
|
|
101
|
+
else if (next.every((c) => c)) setWinner("Draw!");
|
|
102
|
+
return next;
|
|
103
|
+
});
|
|
104
|
+
setCurrentTurn(msg.symbol === "X" ? "O" : "X");
|
|
105
|
+
} else if (msg.type === "join" && msg.userId !== session.user.id) {
|
|
106
|
+
setPlayers((prev) =>
|
|
107
|
+
prev.includes(msg.name) ? prev : [...prev, msg.name]
|
|
108
|
+
);
|
|
109
|
+
// Second player gets O
|
|
110
|
+
setMySymbol((prev) => prev || "X");
|
|
111
|
+
} else if (msg.type === "assign") {
|
|
112
|
+
// Server-side symbol assignment echo
|
|
113
|
+
if (msg.userId === session.user.id && msg.symbol) {
|
|
114
|
+
setMySymbol(msg.symbol);
|
|
115
|
+
}
|
|
116
|
+
} else if (msg.type === "reset" && msg.userId !== session.user.id) {
|
|
117
|
+
setBoard(Array(9).fill(null));
|
|
118
|
+
setCurrentTurn("X");
|
|
119
|
+
setWinner(null);
|
|
120
|
+
}
|
|
121
|
+
} catch { /* ignore */ }
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// First player is X
|
|
125
|
+
setMySymbol("X");
|
|
126
|
+
setPlayers([session.user.name]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function createRoom() {
|
|
130
|
+
const code = Math.random().toString(36).substring(2, 8).toUpperCase();
|
|
131
|
+
joinRoom(code);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function handleJoinExisting() {
|
|
135
|
+
if (joinCode.trim()) {
|
|
136
|
+
joinRoom(joinCode.trim().toUpperCase());
|
|
137
|
+
// Second player joining gets O
|
|
138
|
+
setMySymbol("O");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function copyRoomCode() {
|
|
143
|
+
navigator.clipboard.writeText(roomCode);
|
|
144
|
+
setCopied(true);
|
|
145
|
+
setTimeout(() => setCopied(false), 2000);
|
|
146
|
+
}
|
|
104
147
|
|
|
105
148
|
function handleCellClick(index: number) {
|
|
106
|
-
if (board[index] || winner || !connRef.current) return;
|
|
149
|
+
if (board[index] || winner || !connRef.current || !mySymbol) return;
|
|
107
150
|
if (currentTurn !== mySymbol) return;
|
|
108
151
|
|
|
109
|
-
const stream = connRef.current.stream(
|
|
152
|
+
const stream = connRef.current.stream(streamNameRef.current);
|
|
110
153
|
stream.send(JSON.stringify({
|
|
111
154
|
type: "move",
|
|
112
155
|
cell: index,
|
|
113
156
|
symbol: mySymbol,
|
|
114
157
|
name: session?.user?.name,
|
|
158
|
+
userId: session?.user?.id,
|
|
115
159
|
}));
|
|
160
|
+
|
|
161
|
+
// Apply move locally (skip self-echoes in on("data"))
|
|
162
|
+
setBoard((prev) => {
|
|
163
|
+
const next = [...prev];
|
|
164
|
+
next[index] = mySymbol;
|
|
165
|
+
const w = checkWinner(next);
|
|
166
|
+
if (w) setWinner(`${session?.user?.name} (${mySymbol}) wins!`);
|
|
167
|
+
else if (next.every((c) => c)) setWinner("Draw!");
|
|
168
|
+
return next;
|
|
169
|
+
});
|
|
170
|
+
setCurrentTurn(mySymbol === "X" ? "O" : "X");
|
|
116
171
|
}
|
|
117
172
|
|
|
118
173
|
function handleReset() {
|
|
119
174
|
if (!connRef.current) return;
|
|
120
|
-
const stream = connRef.current.stream(
|
|
121
|
-
stream.send(JSON.stringify({
|
|
175
|
+
const stream = connRef.current.stream(streamNameRef.current);
|
|
176
|
+
stream.send(JSON.stringify({
|
|
177
|
+
type: "reset",
|
|
178
|
+
userId: session?.user?.id,
|
|
179
|
+
}));
|
|
180
|
+
setBoard(Array(9).fill(null));
|
|
181
|
+
setCurrentTurn("X");
|
|
182
|
+
setWinner(null);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function leaveRoom() {
|
|
186
|
+
setInRoom(false);
|
|
187
|
+
setRoomCode("");
|
|
122
188
|
setBoard(Array(9).fill(null));
|
|
123
189
|
setCurrentTurn("X");
|
|
190
|
+
setMySymbol(null);
|
|
124
191
|
setWinner(null);
|
|
192
|
+
setPlayers([]);
|
|
193
|
+
streamNameRef.current = "";
|
|
125
194
|
}
|
|
126
195
|
|
|
127
196
|
return (
|
|
@@ -133,7 +202,7 @@ export default function GameSyncPage() {
|
|
|
133
202
|
</div>
|
|
134
203
|
<div>
|
|
135
204
|
<h1 className="text-2xl font-bold">Game State Sync</h1>
|
|
136
|
-
<p className="text-sm text-slate-500">Real-time tic-tac-toe with
|
|
205
|
+
<p className="text-sm text-slate-500">Real-time tic-tac-toe with room-based multiplayer</p>
|
|
137
206
|
</div>
|
|
138
207
|
</div>
|
|
139
208
|
<div className="flex items-center gap-3">
|
|
@@ -155,14 +224,89 @@ export default function GameSyncPage() {
|
|
|
155
224
|
<div className="flex items-center justify-center py-20">
|
|
156
225
|
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mr-2" /><span className="text-slate-400">Connecting...</span>
|
|
157
226
|
</div>
|
|
227
|
+
) : !inRoom ? (
|
|
228
|
+
/* ── Room Lobby ── */
|
|
229
|
+
<div className="max-w-lg mx-auto">
|
|
230
|
+
<div className="glass rounded-2xl p-8 text-center mb-6">
|
|
231
|
+
<div className="text-5xl mb-4">🎮</div>
|
|
232
|
+
<h2 className="text-xl font-bold mb-2">Join or Create a Game</h2>
|
|
233
|
+
<p className="text-sm text-slate-400 mb-6">
|
|
234
|
+
Create a room and share the code with a friend, or join an existing room to play tic-tac-toe!
|
|
235
|
+
</p>
|
|
236
|
+
|
|
237
|
+
<button
|
|
238
|
+
onClick={createRoom}
|
|
239
|
+
disabled={!connected}
|
|
240
|
+
className="w-full py-3.5 bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-400 hover:to-emerald-500 rounded-xl font-semibold text-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed mb-6"
|
|
241
|
+
>
|
|
242
|
+
🎲 Create New Room
|
|
243
|
+
</button>
|
|
244
|
+
|
|
245
|
+
<div className="relative mb-6">
|
|
246
|
+
<div className="absolute inset-0 flex items-center">
|
|
247
|
+
<div className="w-full border-t border-slate-700" />
|
|
248
|
+
</div>
|
|
249
|
+
<div className="relative flex justify-center text-xs">
|
|
250
|
+
<span className="px-3 bg-slate-800 text-slate-500 uppercase tracking-wider">Or Join Existing</span>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<div className="flex gap-2">
|
|
255
|
+
<input
|
|
256
|
+
type="text"
|
|
257
|
+
value={joinCode}
|
|
258
|
+
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
|
|
259
|
+
placeholder="Enter room code..."
|
|
260
|
+
maxLength={6}
|
|
261
|
+
className="flex-1 px-4 py-3 rounded-xl bg-slate-800/50 border border-slate-700 focus:border-green-500 focus:ring-1 focus:ring-green-500 outline-none text-center text-lg font-mono tracking-wider placeholder:text-slate-600 transition-colors"
|
|
262
|
+
/>
|
|
263
|
+
<button
|
|
264
|
+
onClick={handleJoinExisting}
|
|
265
|
+
disabled={!connected || !joinCode.trim()}
|
|
266
|
+
className="px-6 py-3 bg-emerald-600 hover:bg-emerald-500 rounded-xl font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
267
|
+
>
|
|
268
|
+
<LogIn className="w-4 h-4" />Join
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<div className="glass rounded-xl p-4 text-center">
|
|
274
|
+
<p className="text-xs text-slate-400">
|
|
275
|
+
💡 <span className="text-green-400 font-medium">How It Works</span> — Game state is synced in real-time via Pulse streams.
|
|
276
|
+
Each room gets its own stream, so multiple games can run simultaneously.
|
|
277
|
+
</p>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
158
280
|
) : (
|
|
281
|
+
/* ── Game Board ── */
|
|
159
282
|
<div className="max-w-lg mx-auto">
|
|
283
|
+
{/* Room header */}
|
|
284
|
+
<div className="glass rounded-xl p-4 mb-4 flex items-center justify-between">
|
|
285
|
+
<div className="flex items-center gap-3">
|
|
286
|
+
<button onClick={copyRoomCode}
|
|
287
|
+
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/20 hover:bg-green-500/20 transition-colors">
|
|
288
|
+
<span className="text-xs text-slate-400">Room:</span>
|
|
289
|
+
<span className="text-sm font-mono font-bold text-green-400">{roomCode}</span>
|
|
290
|
+
<Copy className="w-3 h-3 text-green-400" />
|
|
291
|
+
</button>
|
|
292
|
+
{copied && <span className="text-xs text-green-400 animate-pulse">Copied!</span>}
|
|
293
|
+
</div>
|
|
294
|
+
<div className="flex items-center gap-3">
|
|
295
|
+
<div className="flex items-center gap-1.5">
|
|
296
|
+
<Users className="w-3.5 h-3.5 text-slate-400" />
|
|
297
|
+
<span className="text-xs text-slate-400">{players.length} player(s)</span>
|
|
298
|
+
</div>
|
|
299
|
+
<button onClick={leaveRoom}
|
|
300
|
+
className="text-xs text-red-400 hover:text-red-300 transition-colors">Leave</button>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
{/* Game info */}
|
|
160
305
|
<div className="glass rounded-xl p-4 mb-6 flex items-center justify-between">
|
|
161
306
|
<div className="text-sm">You are: <span className={`font-bold text-lg ${mySymbol === "X" ? "text-cyan-400" : "text-pink-400"}`}>{mySymbol}</span></div>
|
|
162
307
|
<div className="text-sm text-slate-400">
|
|
163
308
|
{winner || (<>Turn: <span className={`font-bold ${currentTurn === "X" ? "text-cyan-400" : "text-pink-400"}`}>{currentTurn}</span>{currentTurn === mySymbol && " (your turn)"}</>)}
|
|
164
309
|
</div>
|
|
165
|
-
<div className="text-sm text-slate-400">{Math.max(players.length, 1)} player(s)</div>
|
|
166
310
|
</div>
|
|
167
311
|
|
|
168
312
|
<div className="grid grid-cols-3 gap-3 mb-6">
|
|
@@ -185,6 +329,14 @@ export default function GameSyncPage() {
|
|
|
185
329
|
</button>
|
|
186
330
|
</div>
|
|
187
331
|
)}
|
|
332
|
+
|
|
333
|
+
{players.length < 2 && !winner && (
|
|
334
|
+
<div className="text-center glass rounded-xl p-4 mt-4">
|
|
335
|
+
<Loader2 className="w-5 h-5 animate-spin text-green-400 mx-auto mb-2" />
|
|
336
|
+
<p className="text-sm text-slate-400">Waiting for opponent to join...</p>
|
|
337
|
+
<p className="text-xs text-slate-500 mt-1">Share code <span className="font-mono font-bold text-green-400">{roomCode}</span> with a friend</p>
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
188
340
|
</div>
|
|
189
341
|
)}
|
|
190
342
|
</div>
|