Files
LiveServer-M1/src/app/join/[code]/page.tsx
joylessorchid f6d3f37a5f feat: dark design system + full UI redesign + HTTPS for local dev
- 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://
2026-03-24 12:14:49 +03:00

241 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}