Full MVP implementation: - Next.js 16 + React 19 + TypeScript + Tailwind CSS v4 - LiveKit integration (rooms, tokens, webhooks, moderation) - better-auth (email/password, sessions, roles) - Prisma 7 + PostgreSQL (9 models, 3 enums) - 15 API routes (auth, rooms, lobby, chat, files, moderation, hand-raise) - 7 pages (landing, auth, dashboard, join, video room) - SSE-based waiting room with host approval flow - Security: PIN rate limiting, session fingerprint bans, chat/files auth - Python AI Agent (Deepgram STT + OpenAI summarization) - Docker Compose (local + production with Traefik + Let's Encrypt) - Interactive setup script (setup.sh) - Dev protection middleware (DEV_ACCESS_KEY, ALLOWED_IPS) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
96 lines
2.9 KiB
TypeScript
96 lines
2.9 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useCallback, useRef } from "react";
|
||
|
||
interface LobbyManagerProps {
|
||
roomId: string;
|
||
}
|
||
|
||
type LobbyEntry = {
|
||
id: string;
|
||
sessionId: string;
|
||
displayName: string;
|
||
createdAt: string;
|
||
};
|
||
|
||
export default function LobbyManager({ roomId }: LobbyManagerProps) {
|
||
const [entries, setEntries] = useState<LobbyEntry[]>([]);
|
||
const [acting, setActing] = useState<string | null>(null);
|
||
const pollRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||
|
||
const fetchEntries = useCallback(async () => {
|
||
try {
|
||
const res = await fetch(`/api/rooms/${roomId}/lobby`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setEntries(data);
|
||
}
|
||
} catch {
|
||
// silent
|
||
}
|
||
}, [roomId]);
|
||
|
||
useEffect(() => {
|
||
fetchEntries();
|
||
pollRef.current = setInterval(fetchEntries, 3000);
|
||
return () => {
|
||
if (pollRef.current) clearInterval(pollRef.current);
|
||
};
|
||
}, [fetchEntries]);
|
||
|
||
async function handleAction(entry: LobbyEntry, action: "APPROVED" | "REJECTED") {
|
||
setActing(entry.id);
|
||
try {
|
||
await fetch(`/api/rooms/${roomId}/lobby`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ lobbyEntryId: entry.id, action }),
|
||
});
|
||
setEntries((prev) => prev.filter((e) => e.id !== entry.id));
|
||
} catch {
|
||
// silent
|
||
} finally {
|
||
setActing(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="p-4">
|
||
<h3 className="text-sm font-semibold text-neutral-400 uppercase tracking-wider mb-3">
|
||
Зал ожидания ({entries.length})
|
||
</h3>
|
||
|
||
{entries.length === 0 ? (
|
||
<p className="text-sm text-neutral-500">Никто не ожидает</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{entries.map((entry) => (
|
||
<div
|
||
key={entry.id}
|
||
className="flex items-center justify-between bg-neutral-800 rounded-lg px-3 py-2"
|
||
>
|
||
<span className="text-sm truncate mr-2">{entry.displayName}</span>
|
||
<div className="flex gap-1 shrink-0">
|
||
<button
|
||
onClick={() => handleAction(entry, "APPROVED")}
|
||
disabled={acting === entry.id}
|
||
className="px-2 py-1 text-xs bg-green-600 hover:bg-green-700 disabled:opacity-50 rounded font-medium transition-colors"
|
||
>
|
||
Принять
|
||
</button>
|
||
<button
|
||
onClick={() => handleAction(entry, "REJECTED")}
|
||
disabled={acting === entry.id}
|
||
className="px-2 py-1 text-xs bg-red-600/20 text-red-400 hover:bg-red-600/30 disabled:opacity-50 rounded font-medium transition-colors"
|
||
>
|
||
Отклонить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|