@sansavision/create-pulse 0.4.1 → 0.4.3
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 +125 -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 +36 -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 +14 -0
- package/templates/nextjs-auth-demo/src/lib/db.ts +6 -0
- package/templates/nextjs-auth-demo/src/lib/pulse.ts +45 -0
- package/templates/nextjs-auth-demo/src/lib/schema.ts +107 -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,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
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef } from "react";
|
|
4
|
+
import { useSession } from "@/lib/auth-client";
|
|
5
|
+
import { connectWithAuth } from "@/lib/pulse";
|
|
6
|
+
import {
|
|
7
|
+
Database,
|
|
8
|
+
Send,
|
|
9
|
+
Download,
|
|
10
|
+
Check,
|
|
11
|
+
WifiOff,
|
|
12
|
+
Wifi,
|
|
13
|
+
Shield,
|
|
14
|
+
Loader2,
|
|
15
|
+
Trash2,
|
|
16
|
+
Clock,
|
|
17
|
+
RefreshCw,
|
|
18
|
+
} from "lucide-react";
|
|
19
|
+
import type { PulseConnection } from "@sansavision/pulse-sdk";
|
|
20
|
+
import { PulseQueue } from "@sansavision/pulse-sdk";
|
|
21
|
+
|
|
22
|
+
interface QueueMsg {
|
|
23
|
+
id: number;
|
|
24
|
+
payload: string;
|
|
25
|
+
timestamp: number;
|
|
26
|
+
acked: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function QueueDemoPage() {
|
|
30
|
+
const { data: session } = useSession();
|
|
31
|
+
const [connected, setConnected] = useState(false);
|
|
32
|
+
const [authUser, setAuthUser] = useState<{ id: string; claims: Record<string, string> } | null>(null);
|
|
33
|
+
const [connecting, setConnecting] = useState(true);
|
|
34
|
+
const [simulatedOffline, setSimulatedOffline] = useState(false);
|
|
35
|
+
const [publishInput, setPublishInput] = useState("");
|
|
36
|
+
const [publishedMessages, setPublishedMessages] = useState<QueueMsg[]>([]);
|
|
37
|
+
const [receivedMessages, setReceivedMessages] = useState<QueueMsg[]>([]);
|
|
38
|
+
const connRef = useRef<PulseConnection | null>(null);
|
|
39
|
+
const queueRef = useRef<PulseQueue | null>(null);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!session) return;
|
|
43
|
+
let cancelled = false;
|
|
44
|
+
|
|
45
|
+
async function init() {
|
|
46
|
+
try {
|
|
47
|
+
const connection = await connectWithAuth();
|
|
48
|
+
if (cancelled) return;
|
|
49
|
+
|
|
50
|
+
connRef.current = connection;
|
|
51
|
+
queueRef.current = new PulseQueue(connection, "demo-queue");
|
|
52
|
+
setConnected(true);
|
|
53
|
+
setConnecting(false);
|
|
54
|
+
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
+
const user = (connection as any).user;
|
|
57
|
+
if (user) setAuthUser(user);
|
|
58
|
+
|
|
59
|
+
connection.on("disconnect", () => setConnected(false));
|
|
60
|
+
connection.on("reconnected", () => setConnected(true));
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error("Failed to connect:", err);
|
|
63
|
+
setConnecting(false);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
init();
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
cancelled = true;
|
|
71
|
+
connRef.current?.disconnect();
|
|
72
|
+
};
|
|
73
|
+
}, [session]);
|
|
74
|
+
|
|
75
|
+
async function handlePublish(e: React.FormEvent) {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
if (!publishInput.trim() || !queueRef.current) return;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const result = await queueRef.current.publish(publishInput.trim(), { ttlSecs: 300 });
|
|
81
|
+
|
|
82
|
+
setPublishedMessages((prev) => [
|
|
83
|
+
...prev,
|
|
84
|
+
{
|
|
85
|
+
id: result.sequence || Date.now(),
|
|
86
|
+
payload: publishInput.trim(),
|
|
87
|
+
timestamp: Date.now(),
|
|
88
|
+
acked: false,
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
setPublishInput("");
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.error("Publish failed:", err);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function handleConsume() {
|
|
98
|
+
if (!queueRef.current) return;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const msg = await queueRef.current.pull();
|
|
102
|
+
if (msg) {
|
|
103
|
+
setReceivedMessages((prev) => [
|
|
104
|
+
...prev,
|
|
105
|
+
{
|
|
106
|
+
id: msg.sequence,
|
|
107
|
+
payload: msg.payload,
|
|
108
|
+
timestamp: Date.now(),
|
|
109
|
+
acked: false,
|
|
110
|
+
},
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error("Consume failed:", err);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function handleDrainAll() {
|
|
119
|
+
if (!queueRef.current) return;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const msgs = await queueRef.current.drain(50);
|
|
123
|
+
const mapped = msgs.map((m) => ({
|
|
124
|
+
id: m.sequence,
|
|
125
|
+
payload: m.payload,
|
|
126
|
+
timestamp: Date.now(),
|
|
127
|
+
acked: false,
|
|
128
|
+
}));
|
|
129
|
+
setReceivedMessages((prev) => [...prev, ...mapped]);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.error("Drain failed:", err);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function handleAck(sequence: number) {
|
|
136
|
+
if (!queueRef.current) return;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await queueRef.current.ack(sequence);
|
|
140
|
+
setReceivedMessages((prev) =>
|
|
141
|
+
prev.map((m) => (m.id === sequence ? { ...m, acked: true } : m))
|
|
142
|
+
);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.error("ACK failed:", err);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function toggleOffline() {
|
|
149
|
+
if (simulatedOffline) {
|
|
150
|
+
setSimulatedOffline(false);
|
|
151
|
+
async function reconnect() {
|
|
152
|
+
try {
|
|
153
|
+
const connection = await connectWithAuth();
|
|
154
|
+
connRef.current = connection;
|
|
155
|
+
queueRef.current = new PulseQueue(connection, "demo-queue");
|
|
156
|
+
setConnected(true);
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
158
|
+
const user = (connection as any).user;
|
|
159
|
+
if (user) setAuthUser(user);
|
|
160
|
+
} catch {
|
|
161
|
+
console.error("Reconnect failed");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
reconnect();
|
|
165
|
+
} else {
|
|
166
|
+
setSimulatedOffline(true);
|
|
167
|
+
connRef.current?.disconnect();
|
|
168
|
+
setConnected(false);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div className="p-8">
|
|
174
|
+
<div className="flex items-center justify-between mb-8">
|
|
175
|
+
<div className="flex items-center gap-3">
|
|
176
|
+
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center">
|
|
177
|
+
<Database className="w-6 h-6 text-white" />
|
|
178
|
+
</div>
|
|
179
|
+
<div>
|
|
180
|
+
<h1 className="text-2xl font-bold">Durable Queues</h1>
|
|
181
|
+
<p className="text-sm text-slate-500">
|
|
182
|
+
Persistent store-and-forward with offline simulation
|
|
183
|
+
</p>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
<div className="flex items-center gap-3">
|
|
187
|
+
{authUser && (
|
|
188
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/20">
|
|
189
|
+
<Shield className="w-3.5 h-3.5 text-green-400" />
|
|
190
|
+
<span className="text-xs text-green-400">{authUser.claims.name || authUser.id}</span>
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
<div className="flex items-center gap-2">
|
|
194
|
+
{connected ? (
|
|
195
|
+
<><div className="status-online" /><span className="text-xs text-green-400">Online</span></>
|
|
196
|
+
) : (
|
|
197
|
+
<><div className="status-offline" /><span className="text-xs text-red-400">{simulatedOffline ? "Simulated Offline" : "Disconnected"}</span></>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{connecting ? (
|
|
204
|
+
<div className="flex items-center justify-center py-20">
|
|
205
|
+
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mr-2" />
|
|
206
|
+
<span className="text-slate-400">Connecting to Pulse relay...</span>
|
|
207
|
+
</div>
|
|
208
|
+
) : (
|
|
209
|
+
<>
|
|
210
|
+
{/* Offline simulation */}
|
|
211
|
+
<div className="glass rounded-xl p-5 mb-8 flex items-center justify-between">
|
|
212
|
+
<div>
|
|
213
|
+
<h3 className="text-sm font-semibold mb-1">🧪 Offline Simulation</h3>
|
|
214
|
+
<p className="text-xs text-slate-400">
|
|
215
|
+
{simulatedOffline
|
|
216
|
+
? "You are offline. Messages published by others will queue. Click 'Come Back Online' then 'Drain All' to receive them."
|
|
217
|
+
: "Click 'Go Offline' to simulate a network disconnect. The relay will queue messages for you."}
|
|
218
|
+
</p>
|
|
219
|
+
</div>
|
|
220
|
+
<button onClick={toggleOffline}
|
|
221
|
+
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg font-medium text-sm transition-all ${simulatedOffline
|
|
222
|
+
? "bg-green-600 hover:bg-green-500 text-white"
|
|
223
|
+
: "bg-red-600/20 border border-red-500/30 text-red-400 hover:bg-red-600/30"
|
|
224
|
+
}`}>
|
|
225
|
+
{simulatedOffline ? (<><Wifi className="w-4 h-4" />Come Back Online</>) : (<><WifiOff className="w-4 h-4" />Go Offline</>)}
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<div className="grid lg:grid-cols-2 gap-8">
|
|
230
|
+
{/* Publisher */}
|
|
231
|
+
<div className="glass rounded-2xl p-6">
|
|
232
|
+
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
233
|
+
<Send className="w-4 h-4 text-amber-400" />Publisher
|
|
234
|
+
</h2>
|
|
235
|
+
<form onSubmit={handlePublish} className="flex gap-2 mb-4">
|
|
236
|
+
<input type="text" value={publishInput} onChange={(e) => setPublishInput(e.target.value)}
|
|
237
|
+
placeholder="Message to publish..." disabled={!connected}
|
|
238
|
+
className="flex-1 px-4 py-2.5 rounded-lg bg-slate-800/50 border border-slate-700 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none text-sm transition-colors placeholder:text-slate-600 disabled:opacity-50" />
|
|
239
|
+
<button type="submit" disabled={!connected || !publishInput.trim()}
|
|
240
|
+
className="px-4 py-2.5 bg-amber-600 hover:bg-amber-500 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium">
|
|
241
|
+
Publish
|
|
242
|
+
</button>
|
|
243
|
+
</form>
|
|
244
|
+
<div className="space-y-2 max-h-80 overflow-y-auto">
|
|
245
|
+
{publishedMessages.length === 0 && <p className="text-sm text-slate-500 text-center py-8">No messages published yet</p>}
|
|
246
|
+
{publishedMessages.map((msg) => (
|
|
247
|
+
<div key={msg.id} className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/30 border border-slate-700/50">
|
|
248
|
+
<Clock className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
|
249
|
+
<span className="text-sm flex-1 truncate">{msg.payload}</span>
|
|
250
|
+
<span className="text-xs text-slate-500 font-mono">seq:{msg.id}</span>
|
|
251
|
+
<span className="text-xs text-slate-500">{new Date(msg.timestamp).toLocaleTimeString()}</span>
|
|
252
|
+
</div>
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
{/* Consumer */}
|
|
258
|
+
<div className="glass rounded-2xl p-6">
|
|
259
|
+
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
260
|
+
<Download className="w-4 h-4 text-cyan-400" />Consumer
|
|
261
|
+
</h2>
|
|
262
|
+
<div className="flex gap-2 mb-4">
|
|
263
|
+
<button onClick={handleConsume} disabled={!connected}
|
|
264
|
+
className="flex items-center gap-2 px-4 py-2.5 bg-cyan-600 hover:bg-cyan-500 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium">
|
|
265
|
+
<RefreshCw className="w-3.5 h-3.5" />Pull Next
|
|
266
|
+
</button>
|
|
267
|
+
<button onClick={handleDrainAll} disabled={!connected}
|
|
268
|
+
className="flex items-center gap-2 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium">
|
|
269
|
+
<Download className="w-3.5 h-3.5" />Drain All
|
|
270
|
+
</button>
|
|
271
|
+
<button onClick={() => setReceivedMessages([])}
|
|
272
|
+
className="flex items-center gap-2 px-4 py-2.5 rounded-lg border border-slate-700 hover:bg-slate-800/50 transition-all text-sm text-slate-400">
|
|
273
|
+
<Trash2 className="w-3.5 h-3.5" />Clear
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
<div className="space-y-2 max-h-80 overflow-y-auto">
|
|
277
|
+
{receivedMessages.length === 0 && <p className="text-sm text-slate-500 text-center py-8">No messages consumed yet</p>}
|
|
278
|
+
{receivedMessages.map((msg) => (
|
|
279
|
+
<div key={msg.id}
|
|
280
|
+
className={`flex items-center gap-3 p-3 rounded-lg border ${msg.acked ? "bg-green-500/5 border-green-500/20" : "bg-slate-800/30 border-slate-700/50"}`}>
|
|
281
|
+
{msg.acked ? <Check className="w-3.5 h-3.5 text-green-400 shrink-0" /> : <Clock className="w-3.5 h-3.5 text-amber-400 shrink-0" />}
|
|
282
|
+
<span className="text-sm flex-1 truncate">{msg.payload}</span>
|
|
283
|
+
{!msg.acked && (
|
|
284
|
+
<button onClick={() => handleAck(msg.id)} disabled={!connected}
|
|
285
|
+
className="text-xs px-2 py-1 bg-green-600 hover:bg-green-500 rounded transition-all disabled:opacity-50">ACK</button>
|
|
286
|
+
)}
|
|
287
|
+
<span className="text-xs text-slate-500">{new Date(msg.timestamp).toLocaleTimeString()}</span>
|
|
288
|
+
</div>
|
|
289
|
+
))}
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
</>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
}
|