- Design system: CSS custom properties (surface levels, accent, status colors, scrollbar, focus-visible) - Landing: hero with gradient title, feature cards with SVG icons - Auth pages: consistent card layout with design tokens - Dashboard: sticky top bar, room grid with status dots, empty state - Create room: toggle switches, form validation with spinners - Join flow: room info card with status badge, monospace code input - Room page: top bar with sidebar toggles (chat/lobby/moderation) - Chat: bubble messages with optimistic UI, empty state - Moderation: participant list with avatar initials, kick/ban - Lobby: waiting animation with pulsing rings, approve/reject - HTTPS: dev:https script, setup.sh auto-configures BETTER_AUTH_URL - Auth: trustedOrigins now includes both http:// and https://
241 lines
9.0 KiB
TypeScript
241 lines
9.0 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, type FormEvent } from "react";
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import WaitingRoom from "@/components/lobby/WaitingRoom";
|
||
|
||
type RoomInfo = {
|
||
id: string;
|
||
name: string;
|
||
code: string;
|
||
status: string;
|
||
hostId: string;
|
||
lobbyEnabled: boolean;
|
||
hasPin: boolean;
|
||
webinarMode: boolean;
|
||
};
|
||
|
||
export default function JoinCodePage() {
|
||
const params = useParams<{ code: string }>();
|
||
const router = useRouter();
|
||
|
||
const [room, setRoom] = useState<RoomInfo | null>(null);
|
||
const [displayName, setDisplayName] = useState("");
|
||
const [pin, setPin] = useState("");
|
||
const [error, setError] = useState("");
|
||
const [loading, setLoading] = useState(true);
|
||
const [joining, setJoining] = useState(false);
|
||
const [inLobby, setInLobby] = useState(false);
|
||
const [sessionId] = useState(() => crypto.randomUUID());
|
||
const [sessionFingerprint] = useState(() => {
|
||
// Generate a persistent fingerprint based on browser characteristics
|
||
const nav = typeof navigator !== "undefined" ? navigator : null;
|
||
const raw = [
|
||
nav?.userAgent ?? "",
|
||
nav?.language ?? "",
|
||
screen?.width ?? "",
|
||
screen?.height ?? "",
|
||
Intl.DateTimeFormat().resolvedOptions().timeZone ?? "",
|
||
].join("|");
|
||
// Simple hash
|
||
let hash = 0;
|
||
for (let i = 0; i < raw.length; i++) {
|
||
hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
|
||
}
|
||
return Math.abs(hash).toString(36);
|
||
});
|
||
|
||
useEffect(() => {
|
||
async function fetchRoom() {
|
||
try {
|
||
const res = await fetch(`/api/rooms/by-code/${params.code}`);
|
||
if (!res.ok) {
|
||
setError("Комната не найдена");
|
||
return;
|
||
}
|
||
const roomData = await res.json();
|
||
setRoom(roomData);
|
||
} catch {
|
||
setError("Ошибка загрузки комнаты");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
fetchRoom();
|
||
}, [params.code]);
|
||
|
||
async function handleJoin(e: FormEvent) {
|
||
e.preventDefault();
|
||
if (!room) return;
|
||
setError("");
|
||
setJoining(true);
|
||
|
||
try {
|
||
const res = await fetch(`/api/rooms/${room.id}/join`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
displayName,
|
||
pin: pin || undefined,
|
||
sessionId,
|
||
sessionFingerprint,
|
||
}),
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (!res.ok) {
|
||
setError(data.error ?? "Ошибка при подключении");
|
||
setJoining(false);
|
||
return;
|
||
}
|
||
|
||
if (data.status === "lobby") {
|
||
setInLobby(true);
|
||
} else if (data.status === "approved" && data.token) {
|
||
router.push(`/room/${room.code}?token=${encodeURIComponent(data.token)}`);
|
||
}
|
||
} catch {
|
||
setError("Произошла ошибка. Попробуйте ещё раз.");
|
||
setJoining(false);
|
||
}
|
||
}
|
||
|
||
function handleApproved(token: string) {
|
||
if (room) {
|
||
router.push(`/room/${room.code}?token=${encodeURIComponent(token)}`);
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<main className="min-h-screen flex items-center justify-center bg-surface-0">
|
||
<div className="flex flex-col items-center gap-3">
|
||
<div className="w-8 h-8 border-2 border-surface-3 border-t-accent rounded-full animate-spin" />
|
||
<p className="text-text-secondary text-sm">Загрузка...</p>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
if (inLobby && room) {
|
||
return (
|
||
<WaitingRoom
|
||
roomId={room.id}
|
||
sessionId={sessionId}
|
||
onApproved={handleApproved}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const statusLabel: Record<string, { text: string; color: string }> = {
|
||
active: { text: "Активна", color: "bg-success/10 text-success border-success/20" },
|
||
waiting: { text: "Ожидание", color: "bg-warning/10 text-warning border-warning/20" },
|
||
finished: { text: "Завершена", color: "bg-surface-3/50 text-text-muted border-border-default" },
|
||
};
|
||
|
||
return (
|
||
<main className="min-h-screen flex items-center justify-center px-4 bg-surface-0">
|
||
<div className="w-full max-w-md bg-surface-1 border border-border-default rounded-2xl shadow-xl p-8">
|
||
{room ? (
|
||
<>
|
||
{/* Room info header */}
|
||
<div className="bg-surface-2 border border-border-default rounded-xl p-4 mb-6">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<h1 className="text-lg font-semibold text-white truncate mr-3">{room.name}</h1>
|
||
{statusLabel[room.status] && (
|
||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${statusLabel[room.status].color}`}>
|
||
{statusLabel[room.status].text}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 text-sm text-text-muted">
|
||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m9.86-2.702a4.5 4.5 0 0 0-1.242-7.244l4.5-4.5a4.5 4.5 0 0 1 6.364 6.364l-1.757 1.757" />
|
||
</svg>
|
||
<span className="font-mono tracking-wider">{room.code}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<form onSubmit={handleJoin} className="space-y-4">
|
||
{error && (
|
||
<div className="bg-danger/10 border border-danger/20 text-danger px-4 py-3 rounded-lg text-sm">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label htmlFor="displayName" className="block text-sm font-medium text-text-secondary mb-1.5">
|
||
Ваше имя
|
||
</label>
|
||
<input
|
||
id="displayName"
|
||
type="text"
|
||
required
|
||
value={displayName}
|
||
onChange={(e) => setDisplayName(e.target.value)}
|
||
className="w-full bg-surface-2 border border-border-default rounded-lg px-4 py-3 text-white placeholder:text-text-muted focus:border-accent focus:ring-1 focus:ring-accent outline-none transition"
|
||
placeholder="Иван"
|
||
/>
|
||
</div>
|
||
|
||
{room.hasPin && (
|
||
<div>
|
||
<label htmlFor="pin" className="block text-sm font-medium text-text-secondary mb-1.5">
|
||
PIN-код комнаты
|
||
</label>
|
||
<input
|
||
id="pin"
|
||
type="text"
|
||
required
|
||
value={pin}
|
||
onChange={(e) => setPin(e.target.value)}
|
||
className="w-full bg-surface-2 border border-border-default rounded-lg px-4 py-3 text-white text-center font-mono tracking-widest placeholder:text-text-muted focus:border-accent focus:ring-1 focus:ring-accent outline-none transition"
|
||
placeholder="1234"
|
||
maxLength={8}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={joining}
|
||
className="w-full bg-accent hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg px-4 py-3 font-medium transition mt-2"
|
||
>
|
||
{joining ? (
|
||
<span className="inline-flex items-center gap-2">
|
||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||
</svg>
|
||
Подключение...
|
||
</span>
|
||
) : (
|
||
"Присоединиться"
|
||
)}
|
||
</button>
|
||
</form>
|
||
</>
|
||
) : (
|
||
<div className="text-center py-4">
|
||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-danger/10 mb-4">
|
||
<svg className="w-6 h-6 text-danger" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
||
</svg>
|
||
</div>
|
||
<p className="text-danger mb-1 font-medium">{error || "Комната не найдена"}</p>
|
||
<p className="text-sm text-text-muted mb-6">Проверьте код и попробуйте снова</p>
|
||
<button
|
||
onClick={() => router.push("/join")}
|
||
className="text-accent-hover hover:text-accent transition text-sm font-medium"
|
||
>
|
||
Ввести другой код
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|