@sansavision/create-pulse 0.4.3 → 0.4.5
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 +3 -3
- package/templates/aurora-auth-node-demo/README.md +43 -0
- package/templates/aurora-auth-node-demo/aurora.config.ts +15 -0
- package/templates/aurora-auth-node-demo/bun.lock +679 -0
- package/templates/aurora-auth-node-demo/drizzle.config.ts +9 -0
- package/templates/aurora-auth-node-demo/package.json +39 -0
- package/templates/aurora-auth-node-demo/postcss.config.mjs +7 -0
- package/templates/aurora-auth-node-demo/server.mjs +46 -0
- package/templates/aurora-auth-node-demo/src/actions/createMessage.action.server.ts +31 -0
- package/templates/aurora-auth-node-demo/src/aurora.auth.ts +65 -0
- package/templates/aurora-auth-node-demo/src/lib/auth-client.ts +30 -0
- package/templates/aurora-auth-node-demo/src/lib/auth.server.ts +11 -0
- package/templates/aurora-auth-node-demo/src/lib/auth.ts +30 -0
- package/templates/aurora-auth-node-demo/src/lib/db.ts +6 -0
- package/templates/aurora-auth-node-demo/src/lib/pulse.ts +45 -0
- package/templates/aurora-auth-node-demo/src/lib/schema.ts +107 -0
- package/templates/aurora-auth-node-demo/src/queries/listMessages.server.ts +25 -0
- package/templates/aurora-auth-node-demo/src/routes/api/auth/[...slug]/handler.ts +14 -0
- package/templates/aurora-auth-node-demo/src/routes/api/pulse/verify/handler.ts +55 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.client.tsx +132 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.client.tsx +154 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.client.tsx +640 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.client.tsx +349 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.client.tsx +472 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.client.tsx +375 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.client.tsx +423 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.client.tsx +840 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.client.tsx +722 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.client.tsx +113 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/page.client.tsx +195 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/favicon.ico +0 -0
- package/templates/aurora-auth-node-demo/src/routes/layout.tsx +18 -0
- package/templates/aurora-auth-node-demo/src/routes/page.client.tsx +263 -0
- package/templates/aurora-auth-node-demo/src/routes/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/styles/app.css +96 -0
- package/templates/aurora-auth-node-demo/tsconfig.json +27 -0
- package/templates/aurora-auth-node-demo/tsconfig.tsbuildinfo +1 -0
- package/templates/nextjs-auth-demo/README.md +1 -1
- package/templates/nextjs-auth-demo/next-env.d.ts +1 -1
- package/templates/nextjs-auth-demo/package.json +8 -7
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/arena-game/page.tsx +640 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/chat/page.tsx +124 -23
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +350 -76
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/game-sync/page.tsx +232 -49
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/queues/page.tsx +213 -87
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/video-call/page.tsx +840 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/watch-together/page.tsx +589 -123
- 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
- package/templates/nextjs-auth-node-demo/.env.example +10 -0
- package/templates/nextjs-auth-node-demo/Dockerfile +19 -0
- package/templates/nextjs-auth-node-demo/README.md +159 -0
- package/templates/nextjs-auth-node-demo/_gitignore +33 -0
- package/templates/nextjs-auth-node-demo/drizzle.config.ts +10 -0
- package/templates/nextjs-auth-node-demo/eslint.config.mjs +18 -0
- package/templates/nextjs-auth-node-demo/next-env.d.ts +6 -0
- package/templates/nextjs-auth-node-demo/next.config.ts +7 -0
- package/templates/nextjs-auth-node-demo/package.json +38 -0
- package/templates/nextjs-auth-node-demo/postcss.config.mjs +7 -0
- package/templates/nextjs-auth-node-demo/public/file.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/globe.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/next.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/vercel.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/window.svg +1 -0
- package/templates/nextjs-auth-node-demo/server.mjs +45 -0
- package/templates/nextjs-auth-node-demo/src/app/api/auth/[...all]/route.ts +4 -0
- package/templates/nextjs-auth-node-demo/src/app/api/pulse/verify/route.ts +54 -0
- package/templates/nextjs-auth-node-demo/src/app/auth/sign-in/page.tsx +131 -0
- package/templates/nextjs-auth-node-demo/src/app/auth/sign-up/page.tsx +153 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/arena-game/page.tsx +640 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/chat/page.tsx +349 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +472 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/game-sync/page.tsx +375 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/queues/page.tsx +423 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/video-call/page.tsx +840 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/watch-together/page.tsx +724 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/layout.tsx +113 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/page.tsx +195 -0
- package/templates/nextjs-auth-node-demo/src/app/favicon.ico +0 -0
- package/templates/nextjs-auth-node-demo/src/app/globals.css +96 -0
- package/templates/nextjs-auth-node-demo/src/app/layout.tsx +27 -0
- package/templates/nextjs-auth-node-demo/src/app/page.tsx +254 -0
- package/templates/nextjs-auth-node-demo/src/lib/auth-client.ts +15 -0
- package/templates/nextjs-auth-node-demo/src/lib/auth.ts +14 -0
- package/templates/nextjs-auth-node-demo/src/lib/db.ts +6 -0
- package/templates/nextjs-auth-node-demo/src/lib/pulse.ts +45 -0
- package/templates/nextjs-auth-node-demo/src/lib/schema.ts +107 -0
- package/templates/nextjs-auth-node-demo/tsconfig.json +34 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
|
|
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, Copy, Users, LogIn, AlertCircle } 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" | null>(null);
|
|
19
|
+
const [winner, setWinner] = useState<string | null>(null);
|
|
20
|
+
const [players, setPlayers] = useState<string[]>([]);
|
|
21
|
+
const connRef = useRef<PulseConnection | null>(null);
|
|
22
|
+
// Room management
|
|
23
|
+
const [roomCode, setRoomCode] = useState<string>("");
|
|
24
|
+
const [joinCode, setJoinCode] = useState("");
|
|
25
|
+
const [error, setError] = useState("");
|
|
26
|
+
const [inRoom, setInRoom] = useState(false);
|
|
27
|
+
const [copied, setCopied] = useState(false);
|
|
28
|
+
const streamRef = useRef<ReturnType<PulseConnection["stream"]> | null>(null);
|
|
29
|
+
const mySymbolRef = useRef<"X" | "O" | null>(null);
|
|
30
|
+
const sessionRef = useRef(session);
|
|
31
|
+
|
|
32
|
+
// Keep refs in sync with state
|
|
33
|
+
useEffect(() => { sessionRef.current = session; }, [session]);
|
|
34
|
+
useEffect(() => { mySymbolRef.current = mySymbol; }, [mySymbol]);
|
|
35
|
+
|
|
36
|
+
const checkWinner = useCallback((b: Cell[]): Cell => {
|
|
37
|
+
const lines = [
|
|
38
|
+
[0, 1, 2], [3, 4, 5], [6, 7, 8],
|
|
39
|
+
[0, 3, 6], [1, 4, 7], [2, 5, 8],
|
|
40
|
+
[0, 4, 8], [2, 4, 6],
|
|
41
|
+
];
|
|
42
|
+
for (const [a, c, d] of lines) {
|
|
43
|
+
if (b[a] && b[a] === b[c] && b[a] === b[d]) return b[a];
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
// Connect to relay on mount
|
|
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
|
+
connection.on("disconnect", () => setConnected(false));
|
|
67
|
+
connection.on("reconnected", () => setConnected(true));
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error("Failed to connect:", err);
|
|
70
|
+
setConnecting(false);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
init();
|
|
75
|
+
return () => {
|
|
76
|
+
cancelled = true;
|
|
77
|
+
connRef.current?.disconnect();
|
|
78
|
+
};
|
|
79
|
+
}, [session]);
|
|
80
|
+
|
|
81
|
+
function joinRoom(code: string) {
|
|
82
|
+
if (!connRef.current || !sessionRef.current) return;
|
|
83
|
+
const sess = sessionRef.current;
|
|
84
|
+
|
|
85
|
+
const streamName = `ttt-${code}`;
|
|
86
|
+
const stream = connRef.current.stream(streamName);
|
|
87
|
+
streamRef.current = stream;
|
|
88
|
+
setRoomCode(code);
|
|
89
|
+
setInRoom(true);
|
|
90
|
+
|
|
91
|
+
// Send join message
|
|
92
|
+
stream.send(JSON.stringify({
|
|
93
|
+
type: "join",
|
|
94
|
+
name: sess.user.name,
|
|
95
|
+
userId: sess.user.id,
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
// Listen for game events — uses refs to avoid stale closures
|
|
99
|
+
stream.on("data", (data: Uint8Array) => {
|
|
100
|
+
try {
|
|
101
|
+
const msg = JSON.parse(new TextDecoder().decode(data));
|
|
102
|
+
const currentSession = sessionRef.current;
|
|
103
|
+
if (!currentSession) return;
|
|
104
|
+
const selfId = currentSession.user.id;
|
|
105
|
+
const selfName = currentSession.user.name;
|
|
106
|
+
|
|
107
|
+
if (msg.type === "move" && msg.userId !== selfId) {
|
|
108
|
+
setBoard((prev) => {
|
|
109
|
+
const next = [...prev];
|
|
110
|
+
next[msg.cell] = msg.symbol;
|
|
111
|
+
const w = checkWinner(next);
|
|
112
|
+
if (w) setWinner(`${msg.name} (${w}) wins!`);
|
|
113
|
+
else if (next.every((c) => c)) setWinner("Draw!");
|
|
114
|
+
return next;
|
|
115
|
+
});
|
|
116
|
+
setCurrentTurn(msg.symbol === "X" ? "O" : "X");
|
|
117
|
+
} else if (msg.type === "join" && msg.userId !== selfId) {
|
|
118
|
+
setPlayers((prev) => {
|
|
119
|
+
if (!prev.includes(msg.name)) {
|
|
120
|
+
// Announce presence back with current symbol from ref
|
|
121
|
+
stream.send(JSON.stringify({
|
|
122
|
+
type: "presence",
|
|
123
|
+
name: selfName,
|
|
124
|
+
userId: selfId,
|
|
125
|
+
symbol: mySymbolRef.current
|
|
126
|
+
}));
|
|
127
|
+
return [...prev, msg.name];
|
|
128
|
+
}
|
|
129
|
+
return prev;
|
|
130
|
+
});
|
|
131
|
+
} else if (msg.type === "presence" && msg.userId !== selfId) {
|
|
132
|
+
setPlayers((prev) => prev.includes(msg.name) ? prev : [...prev, msg.name]);
|
|
133
|
+
} else if (msg.type === "reset" && msg.userId !== selfId) {
|
|
134
|
+
setBoard(Array(9).fill(null));
|
|
135
|
+
setCurrentTurn("X");
|
|
136
|
+
setWinner(null);
|
|
137
|
+
}
|
|
138
|
+
} catch { /* ignore */ }
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// First player is X
|
|
142
|
+
setMySymbol("X");
|
|
143
|
+
mySymbolRef.current = "X";
|
|
144
|
+
setPlayers([sess.user.name]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function createRoom() {
|
|
148
|
+
const code = Math.random().toString(36).substring(2, 8).toUpperCase();
|
|
149
|
+
joinRoom(code);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function handleJoinExisting() {
|
|
153
|
+
const cleaned = joinCode.trim().toUpperCase();
|
|
154
|
+
if (cleaned) {
|
|
155
|
+
if (!/^[A-Z0-9]{6}$/.test(cleaned)) {
|
|
156
|
+
setError("Invalid room code format. Must be exactly 6 alphanumeric characters.");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
setError("");
|
|
160
|
+
joinRoom(cleaned);
|
|
161
|
+
// Second player joining gets O — set both state AND ref
|
|
162
|
+
setMySymbol("O");
|
|
163
|
+
mySymbolRef.current = "O";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function copyRoomCode() {
|
|
168
|
+
navigator.clipboard.writeText(roomCode);
|
|
169
|
+
setCopied(true);
|
|
170
|
+
setTimeout(() => setCopied(false), 2000);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function handleCellClick(index: number) {
|
|
174
|
+
if (board[index] || winner || !streamRef.current || !mySymbol) return;
|
|
175
|
+
if (currentTurn !== mySymbol) return;
|
|
176
|
+
|
|
177
|
+
streamRef.current.send(JSON.stringify({
|
|
178
|
+
type: "move",
|
|
179
|
+
cell: index,
|
|
180
|
+
symbol: mySymbol,
|
|
181
|
+
name: session?.user?.name,
|
|
182
|
+
userId: session?.user?.id,
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
// Apply move locally
|
|
186
|
+
setBoard((prev) => {
|
|
187
|
+
const next = [...prev];
|
|
188
|
+
next[index] = mySymbol;
|
|
189
|
+
const w = checkWinner(next);
|
|
190
|
+
if (w) setWinner(`${session?.user?.name} (${mySymbol}) wins!`);
|
|
191
|
+
else if (next.every((c) => c)) setWinner("Draw!");
|
|
192
|
+
return next;
|
|
193
|
+
});
|
|
194
|
+
setCurrentTurn(mySymbol === "X" ? "O" : "X");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function handleReset() {
|
|
198
|
+
if (!streamRef.current) return;
|
|
199
|
+
streamRef.current.send(JSON.stringify({
|
|
200
|
+
type: "reset",
|
|
201
|
+
userId: session?.user?.id,
|
|
202
|
+
}));
|
|
203
|
+
setBoard(Array(9).fill(null));
|
|
204
|
+
setCurrentTurn("X");
|
|
205
|
+
setWinner(null);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function leaveRoom() {
|
|
209
|
+
setInRoom(false);
|
|
210
|
+
setRoomCode("");
|
|
211
|
+
setBoard(Array(9).fill(null));
|
|
212
|
+
setCurrentTurn("X");
|
|
213
|
+
setMySymbol(null);
|
|
214
|
+
mySymbolRef.current = null;
|
|
215
|
+
setWinner(null);
|
|
216
|
+
setPlayers([]);
|
|
217
|
+
streamRef.current = null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div className="p-8">
|
|
222
|
+
<div className="flex items-center justify-between mb-8">
|
|
223
|
+
<div className="flex items-center gap-3">
|
|
224
|
+
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-500 to-emerald-600 flex items-center justify-center">
|
|
225
|
+
<Gamepad2 className="w-6 h-6 text-white" />
|
|
226
|
+
</div>
|
|
227
|
+
<div>
|
|
228
|
+
<h1 className="text-2xl font-bold">Game State Sync</h1>
|
|
229
|
+
<p className="text-sm text-slate-500">Real-time tic-tac-toe with room-based multiplayer</p>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
<div className="flex items-center gap-3">
|
|
233
|
+
{authUser && (
|
|
234
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/20">
|
|
235
|
+
<Shield className="w-3.5 h-3.5 text-green-400" />
|
|
236
|
+
<span className="text-xs text-green-400">{authUser.claims.name || authUser.id}</span>
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
{connected ? (
|
|
240
|
+
<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>
|
|
241
|
+
) : (
|
|
242
|
+
<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>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{connecting ? (
|
|
248
|
+
<div className="flex items-center justify-center py-20">
|
|
249
|
+
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mr-2" /><span className="text-slate-400">Connecting...</span>
|
|
250
|
+
</div>
|
|
251
|
+
) : !inRoom ? (
|
|
252
|
+
/* ── Room Lobby ── */
|
|
253
|
+
<div className="max-w-lg mx-auto">
|
|
254
|
+
<div className="glass rounded-2xl p-8 text-center mb-6">
|
|
255
|
+
<div className="text-5xl mb-4">🎮</div>
|
|
256
|
+
<h2 className="text-xl font-bold mb-2">Join or Create a Game</h2>
|
|
257
|
+
<p className="text-sm text-slate-400 mb-6">
|
|
258
|
+
Create a room and share the code with a friend, or join an existing room to play tic-tac-toe!
|
|
259
|
+
</p>
|
|
260
|
+
|
|
261
|
+
<button
|
|
262
|
+
onClick={createRoom}
|
|
263
|
+
disabled={!connected}
|
|
264
|
+
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"
|
|
265
|
+
>
|
|
266
|
+
🎲 Create New Room
|
|
267
|
+
</button>
|
|
268
|
+
|
|
269
|
+
<div className="relative mb-6">
|
|
270
|
+
<div className="absolute inset-0 flex items-center">
|
|
271
|
+
<div className="w-full border-t border-slate-700" />
|
|
272
|
+
</div>
|
|
273
|
+
<div className="relative flex justify-center text-xs">
|
|
274
|
+
<span className="px-3 bg-slate-800 text-slate-500 uppercase tracking-wider">Or Join Existing</span>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
{error && (
|
|
279
|
+
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center gap-2 text-red-400 text-sm text-left">
|
|
280
|
+
<AlertCircle className="w-4 h-4 shrink-0" />
|
|
281
|
+
<p>{error}</p>
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
<div className="flex gap-2">
|
|
286
|
+
<input
|
|
287
|
+
type="text"
|
|
288
|
+
value={joinCode}
|
|
289
|
+
onChange={(e) => { setJoinCode(e.target.value.toUpperCase()); setError(""); }}
|
|
290
|
+
placeholder="Enter room code..."
|
|
291
|
+
maxLength={6}
|
|
292
|
+
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"
|
|
293
|
+
/>
|
|
294
|
+
<button
|
|
295
|
+
onClick={handleJoinExisting}
|
|
296
|
+
disabled={!connected || !joinCode.trim()}
|
|
297
|
+
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"
|
|
298
|
+
>
|
|
299
|
+
<LogIn className="w-4 h-4" />Join
|
|
300
|
+
</button>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<div className="glass rounded-xl p-4 text-center">
|
|
305
|
+
<p className="text-xs text-slate-400">
|
|
306
|
+
💡 <span className="text-green-400 font-medium">How It Works</span> — Game state is synced in real-time via Pulse streams.
|
|
307
|
+
Each room gets its own stream, so multiple games can run simultaneously.
|
|
308
|
+
</p>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
) : (
|
|
312
|
+
/* ── Game Board ── */
|
|
313
|
+
<div className="max-w-lg mx-auto">
|
|
314
|
+
{/* Room header */}
|
|
315
|
+
<div className="glass rounded-xl p-4 mb-4 flex items-center justify-between">
|
|
316
|
+
<div className="flex items-center gap-3">
|
|
317
|
+
<button onClick={copyRoomCode}
|
|
318
|
+
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">
|
|
319
|
+
<span className="text-xs text-slate-400">Room:</span>
|
|
320
|
+
<span className="text-sm font-mono font-bold text-green-400">{roomCode}</span>
|
|
321
|
+
<Copy className="w-3 h-3 text-green-400" />
|
|
322
|
+
</button>
|
|
323
|
+
{copied && <span className="text-xs text-green-400 animate-pulse">Copied!</span>}
|
|
324
|
+
</div>
|
|
325
|
+
<div className="flex items-center gap-3">
|
|
326
|
+
<div className="flex items-center gap-1.5">
|
|
327
|
+
<Users className="w-3.5 h-3.5 text-slate-400" />
|
|
328
|
+
<span className="text-xs text-slate-400">{players.length} player(s)</span>
|
|
329
|
+
</div>
|
|
330
|
+
<button onClick={leaveRoom}
|
|
331
|
+
className="text-xs text-red-400 hover:text-red-300 transition-colors">Leave</button>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
{/* Game info */}
|
|
336
|
+
<div className="glass rounded-xl p-4 mb-6 flex items-center justify-between">
|
|
337
|
+
<div className="text-sm">You are: <span className={`font-bold text-lg ${mySymbol === "X" ? "text-cyan-400" : "text-pink-400"}`}>{mySymbol}</span></div>
|
|
338
|
+
<div className="text-sm text-slate-400">
|
|
339
|
+
{winner || (<>Turn: <span className={`font-bold ${currentTurn === "X" ? "text-cyan-400" : "text-pink-400"}`}>{currentTurn}</span>{currentTurn === mySymbol && " (your turn)"}</>)}
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
<div className="grid grid-cols-3 gap-3 mb-6">
|
|
344
|
+
{board.map((cell, i) => (
|
|
345
|
+
<button key={i} onClick={() => handleCellClick(i)}
|
|
346
|
+
disabled={!!cell || !!winner || currentTurn !== mySymbol || !connected}
|
|
347
|
+
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"
|
|
348
|
+
} ${!cell && !winner && currentTurn === mySymbol ? "cursor-pointer" : "cursor-default"} disabled:opacity-60`}>
|
|
349
|
+
<span className={cell === "X" ? "text-cyan-400" : "text-pink-400"}>{cell}</span>
|
|
350
|
+
</button>
|
|
351
|
+
))}
|
|
352
|
+
</div>
|
|
353
|
+
|
|
354
|
+
{winner && (
|
|
355
|
+
<div className="text-center">
|
|
356
|
+
<p className="text-xl font-bold mb-4 gradient-text">{winner}</p>
|
|
357
|
+
<button onClick={handleReset}
|
|
358
|
+
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">
|
|
359
|
+
<RotateCcw className="w-4 h-4" />Play Again
|
|
360
|
+
</button>
|
|
361
|
+
</div>
|
|
362
|
+
)}
|
|
363
|
+
|
|
364
|
+
{players.length < 2 && !winner && (
|
|
365
|
+
<div className="text-center glass rounded-xl p-4 mt-4">
|
|
366
|
+
<Loader2 className="w-5 h-5 animate-spin text-green-400 mx-auto mb-2" />
|
|
367
|
+
<p className="text-sm text-slate-400">Waiting for opponent to join...</p>
|
|
368
|
+
<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>
|
|
369
|
+
</div>
|
|
370
|
+
)}
|
|
371
|
+
</div>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
}
|