@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,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
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
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
|
+
Video,
|
|
8
|
+
Play,
|
|
9
|
+
Pause,
|
|
10
|
+
SkipForward,
|
|
11
|
+
Users,
|
|
12
|
+
Shield,
|
|
13
|
+
Wifi,
|
|
14
|
+
WifiOff,
|
|
15
|
+
Loader2,
|
|
16
|
+
} from "lucide-react";
|
|
17
|
+
import type { PulseConnection } from "@sansavision/pulse-sdk";
|
|
18
|
+
|
|
19
|
+
export default function WatchTogetherPage() {
|
|
20
|
+
const { data: session } = useSession();
|
|
21
|
+
const [connected, setConnected] = useState(false);
|
|
22
|
+
const [authUser, setAuthUser] = useState<{ id: string; claims: Record<string, string> } | null>(null);
|
|
23
|
+
const [connecting, setConnecting] = useState(true);
|
|
24
|
+
const [playing, setPlaying] = useState(false);
|
|
25
|
+
const [currentTime, setCurrentTime] = useState(0);
|
|
26
|
+
const [viewers, setViewers] = useState<string[]>([]);
|
|
27
|
+
const [syncEvents, setSyncEvents] = useState<string[]>([]);
|
|
28
|
+
const connRef = useRef<PulseConnection | null>(null);
|
|
29
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!session) return;
|
|
33
|
+
let cancelled = false;
|
|
34
|
+
|
|
35
|
+
async function init() {
|
|
36
|
+
try {
|
|
37
|
+
const connection = await connectWithAuth();
|
|
38
|
+
if (cancelled) return;
|
|
39
|
+
|
|
40
|
+
connRef.current = connection;
|
|
41
|
+
setConnected(true);
|
|
42
|
+
setConnecting(false);
|
|
43
|
+
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
const user = (connection as any).user;
|
|
46
|
+
if (user) setAuthUser(user);
|
|
47
|
+
|
|
48
|
+
const stream = connection.stream("watch-room");
|
|
49
|
+
|
|
50
|
+
stream.send(
|
|
51
|
+
JSON.stringify({ type: "viewer-join", name: session!.user.name })
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
stream.on("data", (data: Uint8Array) => {
|
|
55
|
+
try {
|
|
56
|
+
const msg = JSON.parse(new TextDecoder().decode(data));
|
|
57
|
+
if (msg.type === "play") {
|
|
58
|
+
setPlaying(true);
|
|
59
|
+
setSyncEvents((prev) => [
|
|
60
|
+
`${msg.user} pressed play at ${msg.time?.toFixed(1)}s`,
|
|
61
|
+
...prev.slice(0, 9),
|
|
62
|
+
]);
|
|
63
|
+
} else if (msg.type === "pause") {
|
|
64
|
+
setPlaying(false);
|
|
65
|
+
setSyncEvents((prev) => [
|
|
66
|
+
`${msg.user} paused at ${msg.time?.toFixed(1)}s`,
|
|
67
|
+
...prev.slice(0, 9),
|
|
68
|
+
]);
|
|
69
|
+
} else if (msg.type === "seek") {
|
|
70
|
+
setCurrentTime(msg.time || 0);
|
|
71
|
+
setSyncEvents((prev) => [
|
|
72
|
+
`${msg.user} seeked to ${msg.time?.toFixed(1)}s`,
|
|
73
|
+
...prev.slice(0, 9),
|
|
74
|
+
]);
|
|
75
|
+
} else if (msg.type === "viewer-join") {
|
|
76
|
+
setViewers((prev) =>
|
|
77
|
+
prev.includes(msg.name) ? prev : [...prev, msg.name]
|
|
78
|
+
);
|
|
79
|
+
setSyncEvents((prev) => [
|
|
80
|
+
`${msg.name} joined the watch party`,
|
|
81
|
+
...prev.slice(0, 9),
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
/* ignore */
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
connection.on("disconnect", () => setConnected(false));
|
|
90
|
+
connection.on("reconnected", () => setConnected(true));
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error("Failed to connect:", err);
|
|
93
|
+
setConnecting(false);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
init();
|
|
98
|
+
return () => {
|
|
99
|
+
cancelled = true;
|
|
100
|
+
connRef.current?.disconnect();
|
|
101
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
102
|
+
};
|
|
103
|
+
}, [session]);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (playing) {
|
|
107
|
+
intervalRef.current = setInterval(
|
|
108
|
+
() => setCurrentTime((t) => t + 0.1),
|
|
109
|
+
100
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
113
|
+
}
|
|
114
|
+
return () => {
|
|
115
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
116
|
+
};
|
|
117
|
+
}, [playing]);
|
|
118
|
+
|
|
119
|
+
function sendAction(type: string) {
|
|
120
|
+
if (!connRef.current) return;
|
|
121
|
+
const stream = connRef.current.stream("watch-room");
|
|
122
|
+
stream.send(
|
|
123
|
+
JSON.stringify({
|
|
124
|
+
type,
|
|
125
|
+
user: session?.user?.name,
|
|
126
|
+
time: currentTime,
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function handlePlayPause() {
|
|
132
|
+
const action = playing ? "pause" : "play";
|
|
133
|
+
sendAction(action);
|
|
134
|
+
setPlaying(!playing);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function handleSeek(seconds: number) {
|
|
138
|
+
setCurrentTime((prev) => Math.max(0, prev + seconds));
|
|
139
|
+
sendAction("seek");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const formatTime = (t: number) => {
|
|
143
|
+
const mins = Math.floor(t / 60);
|
|
144
|
+
const secs = Math.floor(t % 60);
|
|
145
|
+
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className="p-8">
|
|
150
|
+
<div className="flex items-center justify-between mb-8">
|
|
151
|
+
<div className="flex items-center gap-3">
|
|
152
|
+
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-pink-500 to-rose-600 flex items-center justify-center">
|
|
153
|
+
<Video className="w-6 h-6 text-white" />
|
|
154
|
+
</div>
|
|
155
|
+
<div>
|
|
156
|
+
<h1 className="text-2xl font-bold">Watch Together</h1>
|
|
157
|
+
<p className="text-sm text-slate-500">
|
|
158
|
+
Synchronized video playback across users
|
|
159
|
+
</p>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
<div className="flex items-center gap-3">
|
|
163
|
+
{authUser && (
|
|
164
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/20">
|
|
165
|
+
<Shield className="w-3.5 h-3.5 text-green-400" />
|
|
166
|
+
<span className="text-xs text-green-400">
|
|
167
|
+
{authUser.claims.name || authUser.id}
|
|
168
|
+
</span>
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
{connected ? (
|
|
172
|
+
<div className="flex items-center gap-2">
|
|
173
|
+
<Wifi className="w-4 h-4 text-green-400" />
|
|
174
|
+
<span className="text-xs text-green-400">Connected</span>
|
|
175
|
+
</div>
|
|
176
|
+
) : (
|
|
177
|
+
<div className="flex items-center gap-2">
|
|
178
|
+
<WifiOff className="w-4 h-4 text-red-400" />
|
|
179
|
+
<span className="text-xs text-red-400">Disconnected</span>
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{connecting ? (
|
|
186
|
+
<div className="flex items-center justify-center py-20">
|
|
187
|
+
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mr-2" />
|
|
188
|
+
<span className="text-slate-400">Connecting...</span>
|
|
189
|
+
</div>
|
|
190
|
+
) : (
|
|
191
|
+
<div className="grid lg:grid-cols-3 gap-8">
|
|
192
|
+
<div className="lg:col-span-2">
|
|
193
|
+
<div className="glass rounded-2xl overflow-hidden">
|
|
194
|
+
<div className="aspect-video bg-gradient-to-br from-slate-900 to-slate-800 flex items-center justify-center relative">
|
|
195
|
+
<div className="text-center">
|
|
196
|
+
<Video className="w-16 h-16 text-slate-600 mx-auto mb-4" />
|
|
197
|
+
<p className="text-slate-500 text-sm">Simulated Video Player</p>
|
|
198
|
+
<p className="text-3xl font-mono text-white mt-2">{formatTime(currentTime)}</p>
|
|
199
|
+
</div>
|
|
200
|
+
<div className="absolute bottom-0 left-0 right-0 h-1 bg-slate-700">
|
|
201
|
+
<div
|
|
202
|
+
className="h-full bg-gradient-to-r from-pink-500 to-rose-500 transition-all"
|
|
203
|
+
style={{ width: `${Math.min((currentTime / 300) * 100, 100)}%` }}
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<div className="p-4 flex items-center gap-4">
|
|
209
|
+
<button onClick={handlePlayPause} disabled={!connected}
|
|
210
|
+
className="w-10 h-10 rounded-full bg-white text-black flex items-center justify-center hover:scale-105 transition-transform disabled:opacity-50">
|
|
211
|
+
{playing ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5 ml-0.5" />}
|
|
212
|
+
</button>
|
|
213
|
+
<button onClick={() => handleSeek(10)} disabled={!connected}
|
|
214
|
+
className="p-2 rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50">
|
|
215
|
+
<SkipForward className="w-4 h-4" />
|
|
216
|
+
</button>
|
|
217
|
+
<span className="text-sm text-slate-400 font-mono">{formatTime(currentTime)} / 5:00</span>
|
|
218
|
+
<div className="flex-1" />
|
|
219
|
+
<div className="flex items-center gap-2">
|
|
220
|
+
<Users className="w-4 h-4 text-slate-400" />
|
|
221
|
+
<span className="text-sm text-slate-400">{Math.max(viewers.length, 1)} watching</span>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<div className="space-y-6">
|
|
228
|
+
<div className="glass rounded-2xl p-6">
|
|
229
|
+
<h3 className="text-sm font-semibold mb-3 text-pink-400">Viewers</h3>
|
|
230
|
+
<div className="space-y-2">
|
|
231
|
+
<div className="flex items-center gap-2">
|
|
232
|
+
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-purple-500 to-cyan-500 flex items-center justify-center text-[10px] font-bold">
|
|
233
|
+
{session?.user?.name?.charAt(0) || "?"}
|
|
234
|
+
</div>
|
|
235
|
+
<span className="text-sm">{session?.user?.name} (you)</span>
|
|
236
|
+
</div>
|
|
237
|
+
{viewers.filter((v) => v !== session?.user?.name).map((v) => (
|
|
238
|
+
<div key={v} className="flex items-center gap-2">
|
|
239
|
+
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-pink-500 to-rose-500 flex items-center justify-center text-[10px] font-bold">{v.charAt(0)}</div>
|
|
240
|
+
<span className="text-sm">{v}</span>
|
|
241
|
+
</div>
|
|
242
|
+
))}
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<div className="glass rounded-2xl p-6">
|
|
247
|
+
<h3 className="text-sm font-semibold mb-3 text-cyan-400">Sync Events</h3>
|
|
248
|
+
<div className="space-y-2 max-h-60 overflow-y-auto">
|
|
249
|
+
{syncEvents.length === 0 && <p className="text-xs text-slate-500">No sync events yet. Press play!</p>}
|
|
250
|
+
{syncEvents.map((event, i) => <div key={i} className="text-xs text-slate-400">{event}</div>)}
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { useSession, signOut } from "@/lib/auth-client";
|
|
6
|
+
import {
|
|
7
|
+
Zap,
|
|
8
|
+
LayoutDashboard,
|
|
9
|
+
MessageSquare,
|
|
10
|
+
Video,
|
|
11
|
+
Database,
|
|
12
|
+
Gamepad2,
|
|
13
|
+
Lock,
|
|
14
|
+
LogOut,
|
|
15
|
+
Loader2,
|
|
16
|
+
ChevronRight,
|
|
17
|
+
} from "lucide-react";
|
|
18
|
+
import { useEffect } from "react";
|
|
19
|
+
|
|
20
|
+
const navItems = [
|
|
21
|
+
{ href: "/dashboard", label: "Overview", icon: LayoutDashboard },
|
|
22
|
+
{ href: "/dashboard/demos/chat", label: "Real-time Chat", icon: MessageSquare },
|
|
23
|
+
{ href: "/dashboard/demos/watch-together", label: "Watch Together", icon: Video },
|
|
24
|
+
{ href: "/dashboard/demos/queues", label: "Durable Queues", icon: Database },
|
|
25
|
+
{ href: "/dashboard/demos/game-sync", label: "Game Sync", icon: Gamepad2 },
|
|
26
|
+
{ href: "/dashboard/demos/encrypted-chat", label: "E2E Encrypted", icon: Lock },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export default function DashboardLayout({
|
|
30
|
+
children,
|
|
31
|
+
}: {
|
|
32
|
+
children: React.ReactNode;
|
|
33
|
+
}) {
|
|
34
|
+
const { data: session, isPending } = useSession();
|
|
35
|
+
const router = useRouter();
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!isPending && !session) {
|
|
39
|
+
router.push("/auth/sign-in");
|
|
40
|
+
}
|
|
41
|
+
}, [session, isPending, router]);
|
|
42
|
+
|
|
43
|
+
if (isPending) {
|
|
44
|
+
return (
|
|
45
|
+
<div className="min-h-screen flex items-center justify-center">
|
|
46
|
+
<Loader2 className="w-8 h-8 animate-spin text-purple-500" />
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!session) return null;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="min-h-screen flex">
|
|
55
|
+
{/* Sidebar */}
|
|
56
|
+
<aside className="w-64 border-r border-slate-800 flex flex-col">
|
|
57
|
+
<div className="p-5 border-b border-slate-800">
|
|
58
|
+
<Link href="/" className="flex items-center gap-2">
|
|
59
|
+
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-purple-500 to-cyan-500 flex items-center justify-center">
|
|
60
|
+
<Zap className="w-5 h-5 text-white" />
|
|
61
|
+
</div>
|
|
62
|
+
<span className="text-lg font-bold">Pulse</span>
|
|
63
|
+
</Link>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<nav className="flex-1 p-3 space-y-1">
|
|
67
|
+
{navItems.map((item) => (
|
|
68
|
+
<Link
|
|
69
|
+
key={item.href}
|
|
70
|
+
href={item.href}
|
|
71
|
+
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-slate-800/50 transition-all group"
|
|
72
|
+
>
|
|
73
|
+
<item.icon className="w-4 h-4" />
|
|
74
|
+
<span className="flex-1">{item.label}</span>
|
|
75
|
+
<ChevronRight className="w-3.5 h-3.5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
76
|
+
</Link>
|
|
77
|
+
))}
|
|
78
|
+
</nav>
|
|
79
|
+
|
|
80
|
+
{/* User info */}
|
|
81
|
+
<div className="p-4 border-t border-slate-800">
|
|
82
|
+
<div className="flex items-center gap-3 mb-3">
|
|
83
|
+
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-cyan-500 flex items-center justify-center text-xs font-bold">
|
|
84
|
+
{session.user.name?.charAt(0)?.toUpperCase() || "?"}
|
|
85
|
+
</div>
|
|
86
|
+
<div className="flex-1 min-w-0">
|
|
87
|
+
<div className="text-sm font-medium truncate">
|
|
88
|
+
{session.user.name}
|
|
89
|
+
</div>
|
|
90
|
+
<div className="text-xs text-slate-500 truncate">
|
|
91
|
+
{session.user.email}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
<button
|
|
96
|
+
onClick={() => signOut().then(() => router.push("/"))}
|
|
97
|
+
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm text-slate-400 hover:text-red-400 hover:bg-red-500/10 transition-all"
|
|
98
|
+
>
|
|
99
|
+
<LogOut className="w-4 h-4" />
|
|
100
|
+
Sign out
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
</aside>
|
|
104
|
+
|
|
105
|
+
{/* Main content */}
|
|
106
|
+
<main className="flex-1 overflow-y-auto">{children}</main>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|