@sansavision/create-pulse 0.4.2 → 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.
@@ -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 { Lock, Send, Shield, Wifi, WifiOff, Loader2, Key, ShieldCheck, Eye, EyeOff } from "lucide-react";
6
+ import { Lock, Send, Shield, Wifi, WifiOff, Loader2, Key, ShieldCheck, Eye, EyeOff, Code, X, ChevronDown } from "lucide-react";
7
7
  import type { PulseConnection } from "@sansavision/pulse-sdk";
8
8
 
9
9
  interface EncryptedMessage {
@@ -12,8 +12,26 @@ interface EncryptedMessage {
12
12
  plaintext: string;
13
13
  ciphertext: string;
14
14
  timestamp: number;
15
+ type: "text" | "code";
16
+ language?: string;
15
17
  }
16
18
 
19
+ const CODE_LANGUAGES = [
20
+ { value: "javascript", label: "JavaScript" },
21
+ { value: "typescript", label: "TypeScript" },
22
+ { value: "python", label: "Python" },
23
+ { value: "rust", label: "Rust" },
24
+ { value: "go", label: "Go" },
25
+ { value: "java", label: "Java" },
26
+ { value: "css", label: "CSS" },
27
+ { value: "html", label: "HTML" },
28
+ { value: "sql", label: "SQL" },
29
+ { value: "bash", label: "Bash" },
30
+ { value: "json", label: "JSON" },
31
+ { value: "yaml", label: "YAML" },
32
+ { value: "plaintext", label: "Plain Text" },
33
+ ];
34
+
17
35
  export default function EncryptedChatPage() {
18
36
  const { data: session } = useSession();
19
37
  const [connected, setConnected] = useState(false);
@@ -22,12 +40,17 @@ export default function EncryptedChatPage() {
22
40
  const [messages, setMessages] = useState<EncryptedMessage[]>([]);
23
41
  const [input, setInput] = useState("");
24
42
  const [showCiphertext, setShowCiphertext] = useState(false);
43
+ const [showCodeEditor, setShowCodeEditor] = useState(false);
44
+ const [codeContent, setCodeContent] = useState("");
45
+ const [codeLanguage, setCodeLanguage] = useState("javascript");
46
+ const [showLangDropdown, setShowLangDropdown] = useState(false);
25
47
  const [encryptionKey] = useState(() =>
26
48
  Array.from(crypto.getRandomValues(new Uint8Array(16)))
27
49
  .map((b) => b.toString(16).padStart(2, "0")).join("")
28
50
  );
29
51
  const connRef = useRef<PulseConnection | null>(null);
30
52
  const messagesEndRef = useRef<HTMLDivElement>(null);
53
+ const codeTextareaRef = useRef<HTMLTextAreaElement>(null);
31
54
 
32
55
  const scrollToBottom = useCallback(() => {
33
56
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -65,10 +88,12 @@ export default function EncryptedChatPage() {
65
88
 
66
89
  const stream = connection.stream("encrypted-room");
67
90
 
91
+ // Listen for messages (skip self-echoes — we add our own messages locally)
68
92
  stream.on("data", (data: Uint8Array) => {
69
93
  try {
70
94
  const msg = JSON.parse(new TextDecoder().decode(data));
71
- if (msg.type === "encrypted-msg") {
95
+ const selfName = session?.user?.name || "Anonymous";
96
+ if ((msg.type === "encrypted-msg" || msg.type === "encrypted-code") && msg.sender !== selfName) {
72
97
  const decrypted = simpleDecrypt(msg.ciphertext, encryptionKey);
73
98
  setMessages((prev) => [
74
99
  ...prev,
@@ -78,6 +103,8 @@ export default function EncryptedChatPage() {
78
103
  plaintext: decrypted,
79
104
  ciphertext: msg.ciphertext,
80
105
  timestamp: Date.now(),
106
+ type: msg.type === "encrypted-code" ? "code" : "text",
107
+ language: msg.language,
81
108
  },
82
109
  ]);
83
110
  }
@@ -108,12 +135,87 @@ export default function EncryptedChatPage() {
108
135
 
109
136
  const plaintext = input.trim();
110
137
  const ciphertext = simpleEncrypt(plaintext, encryptionKey);
138
+ const sender = session?.user?.name || "Anonymous";
111
139
 
112
140
  const stream = connRef.current.stream("encrypted-room");
113
- stream.send(JSON.stringify({ type: "encrypted-msg", sender: session?.user?.name || "Anonymous", ciphertext }));
141
+ stream.send(JSON.stringify({ type: "encrypted-msg", sender, ciphertext }));
142
+
143
+ // Add to local state immediately (relay echoes, but we filter self in on("data"))
144
+ setMessages((prev) => [
145
+ ...prev,
146
+ {
147
+ id: crypto.randomUUID(),
148
+ sender,
149
+ plaintext,
150
+ ciphertext,
151
+ timestamp: Date.now(),
152
+ type: "text",
153
+ },
154
+ ]);
114
155
  setInput("");
115
156
  }
116
157
 
158
+ function sendCode() {
159
+ if (!codeContent.trim() || !connRef.current) return;
160
+
161
+ const plaintext = codeContent.trim();
162
+ const ciphertext = simpleEncrypt(plaintext, encryptionKey);
163
+ const sender = session?.user?.name || "Anonymous";
164
+
165
+ const stream = connRef.current.stream("encrypted-room");
166
+ stream.send(JSON.stringify({
167
+ type: "encrypted-code",
168
+ sender,
169
+ ciphertext,
170
+ language: codeLanguage,
171
+ }));
172
+
173
+ // Add to local state immediately
174
+ setMessages((prev) => [
175
+ ...prev,
176
+ {
177
+ id: crypto.randomUUID(),
178
+ sender,
179
+ plaintext,
180
+ ciphertext,
181
+ timestamp: Date.now(),
182
+ type: "code",
183
+ language: codeLanguage,
184
+ },
185
+ ]);
186
+ setCodeContent("");
187
+ setShowCodeEditor(false);
188
+ }
189
+
190
+ function handleCodeKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
191
+ // Handle Tab key for indentation
192
+ if (e.key === "Tab") {
193
+ e.preventDefault();
194
+ const textarea = e.currentTarget;
195
+ const start = textarea.selectionStart;
196
+ const end = textarea.selectionEnd;
197
+ const newValue = codeContent.substring(0, start) + " " + codeContent.substring(end);
198
+ setCodeContent(newValue);
199
+ // Set cursor position after the tab
200
+ setTimeout(() => {
201
+ textarea.selectionStart = textarea.selectionEnd = start + 2;
202
+ }, 0);
203
+ }
204
+ // Cmd/Ctrl+Enter to send
205
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
206
+ e.preventDefault();
207
+ sendCode();
208
+ }
209
+ }
210
+
211
+ function openCodeEditor() {
212
+ setShowCodeEditor(true);
213
+ setTimeout(() => codeTextareaRef.current?.focus(), 100);
214
+ }
215
+
216
+ const getLanguageLabel = (val: string) =>
217
+ CODE_LANGUAGES.find((l) => l.value === val)?.label || val;
218
+
117
219
  return (
118
220
  <div className="h-screen flex flex-col">
119
221
  <div className="p-4 border-b border-slate-800">
@@ -169,10 +271,37 @@ export default function EncryptedChatPage() {
169
271
  {messages.map((msg) => (
170
272
  <div key={msg.id}>
171
273
  <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>
274
+ {msg.type === "code" ? (
275
+ /* Code snippet message */
276
+ <div className={`max-w-lg w-full rounded-2xl overflow-hidden border ${msg.sender === session?.user?.name
277
+ ? "border-purple-500/30 bg-purple-950/30"
278
+ : "border-slate-700 bg-slate-800/50"
279
+ }`}>
280
+ <div className="flex items-center justify-between px-4 py-2 border-b border-slate-700/50 bg-slate-900/50">
281
+ <div className="flex items-center gap-2">
282
+ <Code className="w-3.5 h-3.5 text-purple-400" />
283
+ <span className="text-xs font-medium text-purple-400">
284
+ {getLanguageLabel(msg.language || "plaintext")}
285
+ </span>
286
+ </div>
287
+ <div className="flex items-center gap-2">
288
+ <Lock className="w-3 h-3 text-green-400" />
289
+ {msg.sender !== session?.user?.name && (
290
+ <span className="text-xs text-slate-400">{msg.sender}</span>
291
+ )}
292
+ </div>
293
+ </div>
294
+ <pre className="p-4 text-sm font-mono overflow-x-auto text-slate-200 leading-relaxed">
295
+ <code>{msg.plaintext}</code>
296
+ </pre>
297
+ </div>
298
+ ) : (
299
+ /* Normal text message */
300
+ <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"}`}>
301
+ {msg.sender !== session?.user?.name && <div className="text-xs text-purple-400 font-medium mb-1">{msg.sender}</div>}
302
+ <div className="flex items-center gap-1.5"><Lock className="w-3 h-3 text-green-400 shrink-0" />{msg.plaintext}</div>
303
+ </div>
304
+ )}
176
305
  </div>
177
306
  {showCiphertext && (
178
307
  <div className={`mt-1 text-xs font-mono text-slate-600 ${msg.sender === session?.user?.name ? "text-right" : "text-left"} px-4`}>
@@ -184,7 +313,91 @@ export default function EncryptedChatPage() {
184
313
  <div ref={messagesEndRef} />
185
314
  </div>
186
315
 
316
+ {/* Code Editor Panel */}
317
+ {showCodeEditor && (
318
+ <div className="border-t border-slate-800 bg-slate-900/80 backdrop-blur">
319
+ <div className="flex items-center justify-between px-4 py-2 border-b border-slate-800">
320
+ <div className="flex items-center gap-3">
321
+ <div className="flex items-center gap-2">
322
+ <Code className="w-4 h-4 text-purple-400" />
323
+ <span className="text-sm font-medium text-purple-400">Share Code Snippet</span>
324
+ </div>
325
+ {/* Language selector */}
326
+ <div className="relative">
327
+ <button
328
+ onClick={() => setShowLangDropdown(!showLangDropdown)}
329
+ className="flex items-center gap-1.5 px-3 py-1 rounded-lg bg-slate-800 border border-slate-700 hover:border-slate-600 transition-colors text-xs"
330
+ >
331
+ {getLanguageLabel(codeLanguage)}
332
+ <ChevronDown className="w-3 h-3 text-slate-400" />
333
+ </button>
334
+ {showLangDropdown && (
335
+ <div className="absolute bottom-full left-0 mb-1 w-44 max-h-52 overflow-y-auto rounded-lg bg-slate-800 border border-slate-700 shadow-xl z-50">
336
+ {CODE_LANGUAGES.map((lang) => (
337
+ <button
338
+ key={lang.value}
339
+ onClick={() => { setCodeLanguage(lang.value); setShowLangDropdown(false); }}
340
+ className={`w-full text-left px-3 py-2 text-xs hover:bg-slate-700 transition-colors ${codeLanguage === lang.value ? "text-purple-400 bg-purple-500/10" : "text-slate-300"
341
+ }`}
342
+ >
343
+ {lang.label}
344
+ </button>
345
+ ))}
346
+ </div>
347
+ )}
348
+ </div>
349
+ </div>
350
+ <div className="flex items-center gap-2">
351
+ <span className="text-[10px] text-slate-500">⌘+Enter to send</span>
352
+ <button
353
+ onClick={() => { setShowCodeEditor(false); setCodeContent(""); }}
354
+ className="p-1 rounded hover:bg-slate-800 transition-colors"
355
+ >
356
+ <X className="w-4 h-4 text-slate-400" />
357
+ </button>
358
+ </div>
359
+ </div>
360
+ <div className="p-3">
361
+ <textarea
362
+ ref={codeTextareaRef}
363
+ value={codeContent}
364
+ onChange={(e) => setCodeContent(e.target.value)}
365
+ onKeyDown={handleCodeKeyDown}
366
+ placeholder="Paste or type your code here..."
367
+ spellCheck={false}
368
+ className="w-full h-32 px-4 py-3 rounded-xl bg-slate-950 border border-slate-700 focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none text-sm font-mono text-slate-200 resize-none transition-colors placeholder:text-slate-600 leading-relaxed"
369
+ />
370
+ <div className="flex justify-end gap-2 mt-2">
371
+ <button
372
+ onClick={() => { setShowCodeEditor(false); setCodeContent(""); }}
373
+ className="px-4 py-2 rounded-lg text-xs text-slate-400 hover:bg-slate-800 transition-colors"
374
+ >
375
+ Cancel
376
+ </button>
377
+ <button
378
+ onClick={sendCode}
379
+ disabled={!connected || !codeContent.trim()}
380
+ className="px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-500 text-xs font-semibold transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
381
+ >
382
+ <Lock className="w-3 h-3" />
383
+ Send Encrypted
384
+ </button>
385
+ </div>
386
+ </div>
387
+ </div>
388
+ )}
389
+
390
+ {/* Message input */}
187
391
  <form onSubmit={sendMessage} className="p-4 border-t border-slate-800 flex gap-3">
392
+ <button
393
+ type="button"
394
+ onClick={openCodeEditor}
395
+ disabled={!connected}
396
+ className="px-3 py-2.5 rounded-xl bg-slate-800/50 border border-slate-700 hover:border-purple-500 hover:bg-purple-500/10 transition-all disabled:opacity-50 disabled:cursor-not-allowed group"
397
+ title="Share code snippet"
398
+ >
399
+ <Code className="w-4 h-4 text-slate-400 group-hover:text-purple-400 transition-colors" />
400
+ </button>
188
401
  <input type="text" value={input} onChange={(e) => setInput(e.target.value)} placeholder="Type an encrypted message..."
189
402
  disabled={!connected}
190
403
  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" />
@@ -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">("X");
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, checkWinner]);
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("game-room");
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("game-room");
121
- stream.send(JSON.stringify({ type: "reset" }));
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 state sync</p>
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>