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://
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
"description": "Livekit core - Server for VideoHosting",
|
"description": "Livekit core - Server for VideoHosting",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"dev:https": "next dev --experimental-https",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
|
|||||||
15
setup.sh
15
setup.sh
@@ -1479,10 +1479,13 @@ OVERRIDE
|
|||||||
[[ -d ".next" ]] && rm -rf .next
|
[[ -d ".next" ]] && rm -rf .next
|
||||||
log_ok "Кеш очищен"
|
log_ok "Кеш очищен"
|
||||||
|
|
||||||
# Автоопределение BETTER_AUTH_URL (если не задан)
|
# Автоопределение BETTER_AUTH_URL (https для dev)
|
||||||
if ! grep -q "^BETTER_AUTH_URL=" .env 2>/dev/null; then
|
if grep -q "^BETTER_AUTH_URL=" .env 2>/dev/null; then
|
||||||
echo "BETTER_AUTH_URL=http://localhost:3000" >> .env
|
# Обновить на https если ещё http
|
||||||
log_ok "BETTER_AUTH_URL=http://localhost:3000 добавлен в .env"
|
sed -i 's|^BETTER_AUTH_URL=http://|BETTER_AUTH_URL=https://|' .env
|
||||||
|
else
|
||||||
|
echo "BETTER_AUTH_URL=https://localhost:3000" >> .env
|
||||||
|
log_ok "BETTER_AUTH_URL=https://localhost:3000 добавлен в .env"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Проверка порта 3000
|
# Проверка порта 3000
|
||||||
@@ -1492,9 +1495,9 @@ OVERRIDE
|
|||||||
|
|
||||||
# Запуск
|
# Запуск
|
||||||
echo ""
|
echo ""
|
||||||
log_ok "${GREEN}${BOLD}Готово! Запускаю dev-сервер на порту 3000...${NC}"
|
log_ok "${GREEN}${BOLD}Готово! Запускаю dev-сервер (HTTPS) на порту 3000...${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
exec npm run dev
|
exec npm run dev:https
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -45,94 +45,139 @@ export default function CreateRoomPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center px-4">
|
<main className="min-h-screen bg-surface-0 text-white">
|
||||||
<div className="w-full max-w-md">
|
{/* Top bar */}
|
||||||
|
<header className="border-b border-border-subtle bg-surface-0/80 backdrop-blur-sm sticky top-0 z-10">
|
||||||
|
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center">
|
||||||
|
<Link href="/dashboard" className="flex items-center gap-2.5">
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-accent flex items-center justify-center">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7" />
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-semibold tracking-tight">LiveServer</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="max-w-lg mx-auto px-6 py-10">
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="text-sm text-neutral-400 hover:text-white transition-colors mb-6 inline-block"
|
className="inline-flex items-center gap-1.5 text-sm text-text-secondary hover:text-white transition-colors mb-8"
|
||||||
>
|
>
|
||||||
← Назад к комнатам
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<polyline points="12 19 5 12 12 5" />
|
||||||
|
</svg>
|
||||||
|
Назад к комнатам
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold mb-8">Создать комнату</h1>
|
<div className="bg-surface-1 border border-border-subtle rounded-2xl p-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight mb-8">Создать комнату</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-600/20 border border-red-600/50 text-red-400 px-4 py-3 rounded-lg text-sm">
|
<div className="bg-danger/10 border border-danger/20 text-danger px-4 py-3 rounded-xl text-sm">
|
||||||
{error}
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Название комнаты
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-surface-2 border border-border-default rounded-xl text-white placeholder:text-text-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
|
||||||
|
placeholder="Лекция по математике"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="name" className="block text-sm text-neutral-400 mb-1">
|
<label htmlFor="pin" className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
Название комнаты
|
PIN-код
|
||||||
</label>
|
</label>
|
||||||
<input
|
|
||||||
id="name"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
|
|
||||||
placeholder="Лекция по математике"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="pin" className="block text-sm text-neutral-400 mb-1">
|
|
||||||
PIN-код (необязательно)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="pin"
|
|
||||||
type="text"
|
|
||||||
value={pin}
|
|
||||||
onChange={(e) => setPin(e.target.value)}
|
|
||||||
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
|
|
||||||
placeholder="1234"
|
|
||||||
maxLength={8}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
id="pin"
|
||||||
checked={lobbyEnabled}
|
type="text"
|
||||||
onChange={(e) => setLobbyEnabled(e.target.checked)}
|
value={pin}
|
||||||
className="w-4 h-4 rounded bg-neutral-800 border-neutral-700 text-blue-600 focus:ring-blue-600 focus:ring-offset-0"
|
onChange={(e) => setPin(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-surface-2 border border-border-default rounded-xl text-white placeholder:text-text-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
|
||||||
|
placeholder="Оставьте пустым, если не нужен"
|
||||||
|
maxLength={8}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">
|
</div>
|
||||||
Зал ожидания
|
|
||||||
<span className="text-neutral-400 ml-1">
|
|
||||||
— участники ждут одобрения хоста
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<div className="space-y-4 pt-2">
|
||||||
<input
|
{/* Lobby toggle */}
|
||||||
type="checkbox"
|
<div className="flex items-center justify-between">
|
||||||
checked={webinarMode}
|
<div>
|
||||||
onChange={(e) => setWebinarMode(e.target.checked)}
|
<p className="text-sm font-medium">Зал ожидания</p>
|
||||||
className="w-4 h-4 rounded bg-neutral-800 border-neutral-700 text-blue-600 focus:ring-blue-600 focus:ring-offset-0"
|
<p className="text-xs text-text-muted mt-0.5">Гости ждут одобрения хоста</p>
|
||||||
/>
|
</div>
|
||||||
<span className="text-sm">
|
<button
|
||||||
Режим вебинара
|
type="button"
|
||||||
<span className="text-neutral-400 ml-1">
|
role="switch"
|
||||||
— только хост может говорить
|
aria-checked={lobbyEnabled}
|
||||||
</span>
|
onClick={() => setLobbyEnabled(!lobbyEnabled)}
|
||||||
</span>
|
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition-colors duration-200 ${
|
||||||
</label>
|
lobbyEnabled ? "bg-accent" : "bg-surface-3"
|
||||||
</div>
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm transform transition-transform duration-200 translate-y-0.5 ${
|
||||||
|
lobbyEnabled ? "translate-x-5.5" : "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
{/* Webinar mode toggle */}
|
||||||
type="submit"
|
<div className="flex items-center justify-between">
|
||||||
disabled={loading}
|
<div>
|
||||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors"
|
<p className="text-sm font-medium">Режим вебинара</p>
|
||||||
>
|
<p className="text-xs text-text-muted mt-0.5">Только хост может говорить</p>
|
||||||
{loading ? "Создание..." : "Создать"}
|
</div>
|
||||||
</button>
|
<button
|
||||||
</form>
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={webinarMode}
|
||||||
|
onClick={() => setWebinarMode(!webinarMode)}
|
||||||
|
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition-colors duration-200 ${
|
||||||
|
webinarMode ? "bg-accent" : "bg-surface-3"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm transform transition-transform duration-200 translate-y-0.5 ${
|
||||||
|
webinarMode ? "translate-x-5.5" : "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 bg-accent hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed rounded-xl font-medium transition-colors text-sm mt-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />
|
||||||
|
Создание...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Создать комнату"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,10 +19,16 @@ const statusLabels: Record<Room["status"], string> = {
|
|||||||
ENDED: "Завершена",
|
ENDED: "Завершена",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusColors: Record<Room["status"], string> = {
|
const statusDotColors: Record<Room["status"], string> = {
|
||||||
WAITING: "bg-yellow-600/20 text-yellow-400",
|
WAITING: "bg-warning",
|
||||||
ACTIVE: "bg-green-600/20 text-green-400",
|
ACTIVE: "bg-success",
|
||||||
ENDED: "bg-neutral-600/20 text-neutral-400",
|
ENDED: "bg-zinc-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusTextColors: Record<Room["status"], string> = {
|
||||||
|
WAITING: "text-warning",
|
||||||
|
ACTIVE: "text-success",
|
||||||
|
ENDED: "text-zinc-500",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
@@ -59,72 +65,126 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center">
|
<main className="min-h-screen bg-surface-0 flex items-center justify-center">
|
||||||
<p className="text-neutral-400">Загрузка...</p>
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-5 w-5 rounded-full border-2 border-accent border-t-transparent animate-spin" />
|
||||||
|
<p className="text-text-secondary text-sm">Загрузка...</p>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen px-4 py-8 max-w-4xl mx-auto">
|
<main className="min-h-screen bg-surface-0 text-white">
|
||||||
<div className="flex items-center justify-between mb-8">
|
{/* Top bar */}
|
||||||
<h1 className="text-2xl font-bold">Мои комнаты</h1>
|
<header className="border-b border-border-subtle bg-surface-0/80 backdrop-blur-sm sticky top-0 z-10">
|
||||||
<div className="flex gap-3">
|
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||||
|
<Link href="/dashboard" className="flex items-center gap-2.5">
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-accent flex items-center justify-center">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7" />
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-semibold tracking-tight">LiveServer</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-surface-2 flex items-center justify-center text-sm font-medium text-text-secondary">
|
||||||
|
{(session?.user?.name?.[0] || session?.user?.email?.[0] || "U").toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-text-secondary hidden sm:block">
|
||||||
|
{session?.user?.name || session?.user?.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => signOut().then(() => router.push("/"))}
|
||||||
|
className="px-4 py-2 text-sm text-text-secondary hover:text-white hover:bg-surface-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Мои комнаты</h1>
|
||||||
|
{!loading && rooms.length > 0 && (
|
||||||
|
<p className="text-sm text-text-muted mt-1">{rooms.length} {rooms.length === 1 ? "комната" : rooms.length < 5 ? "комнаты" : "комнат"}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/create"
|
href="/dashboard/create"
|
||||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition-colors text-sm"
|
className="inline-flex items-center gap-2 px-5 py-2.5 bg-accent hover:bg-accent-hover rounded-xl font-medium transition-colors text-sm"
|
||||||
>
|
>
|
||||||
|
<span className="text-lg leading-none">+</span>
|
||||||
Создать комнату
|
Создать комнату
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
|
||||||
onClick={() => signOut().then(() => router.push("/"))}
|
|
||||||
className="px-5 py-2 bg-neutral-800 hover:bg-neutral-700 rounded-lg font-medium transition-colors text-sm"
|
|
||||||
>
|
|
||||||
Выйти
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-neutral-400">Загрузка комнат...</p>
|
<div className="flex items-center justify-center py-20">
|
||||||
) : rooms.length === 0 ? (
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-center py-20">
|
<div className="h-5 w-5 rounded-full border-2 border-accent border-t-transparent animate-spin" />
|
||||||
<p className="text-neutral-400 mb-4">У вас пока нет комнат</p>
|
<p className="text-text-secondary text-sm">Загрузка комнат...</p>
|
||||||
<Link
|
</div>
|
||||||
href="/dashboard/create"
|
</div>
|
||||||
className="text-blue-500 hover:text-blue-400"
|
) : rooms.length === 0 ? (
|
||||||
>
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
Создать первую комнату
|
<div className="h-16 w-16 rounded-2xl bg-surface-1 border border-border-subtle flex items-center justify-center mb-5">
|
||||||
</Link>
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-text-muted">
|
||||||
</div>
|
<polygon points="23 7 16 12 23 17 23 7" />
|
||||||
) : (
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
</svg>
|
||||||
{rooms.map((room) => (
|
</div>
|
||||||
|
<p className="text-text-secondary mb-1 text-lg">У вас пока нет комнат</p>
|
||||||
|
<p className="text-text-muted text-sm mb-6">Создайте первую комнату для видеоконференции</p>
|
||||||
<Link
|
<Link
|
||||||
key={room.id}
|
href="/dashboard/create"
|
||||||
href={`/room/${room.code}`}
|
className="inline-flex items-center gap-2 px-5 py-2.5 bg-accent hover:bg-accent-hover rounded-xl font-medium transition-colors text-sm"
|
||||||
className="block bg-neutral-900 border border-neutral-800 rounded-lg p-5 hover:border-neutral-700 transition-colors"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<span className="text-lg leading-none">+</span>
|
||||||
<h2 className="font-semibold text-lg">{room.name}</h2>
|
Создать комнату
|
||||||
<span
|
|
||||||
className={`text-xs px-2.5 py-1 rounded-full font-medium ${statusColors[room.status]}`}
|
|
||||||
>
|
|
||||||
{statusLabels[room.status]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-sm text-neutral-400">
|
|
||||||
<span className="font-mono bg-neutral-800 px-2 py-0.5 rounded">
|
|
||||||
{room.code}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{new Date(room.createdAt).toLocaleDateString("ru-RU")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{rooms.map((room) => (
|
||||||
|
<Link
|
||||||
|
key={room.id}
|
||||||
|
href={`/room/${room.code}`}
|
||||||
|
className="block bg-surface-1 border border-border-subtle rounded-xl p-5 hover:border-border-default transition-colors cursor-pointer group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<h2 className="font-semibold text-base truncate pr-3 group-hover:text-white transition-colors">
|
||||||
|
{room.name}
|
||||||
|
</h2>
|
||||||
|
<div className={`flex items-center gap-1.5 shrink-0 text-xs font-medium ${statusTextColors[room.status]}`}>
|
||||||
|
<span className={`h-2 w-2 rounded-full ${statusDotColors[room.status]}`} />
|
||||||
|
{statusLabels[room.status]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="font-mono text-xs bg-surface-2 text-text-secondary px-2.5 py-1 rounded-lg">
|
||||||
|
{room.code}
|
||||||
|
</span>
|
||||||
|
<span className="text-text-muted text-xs">
|
||||||
|
{new Date(room.createdAt).toLocaleDateString("ru-RU", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,88 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ── Design tokens ── */
|
||||||
|
@theme {
|
||||||
|
--color-surface-0: #0f0f0f;
|
||||||
|
--color-surface-1: #1a1a1a;
|
||||||
|
--color-surface-2: #252525;
|
||||||
|
--color-surface-3: #2f2f2f;
|
||||||
|
|
||||||
|
--color-accent: #6366f1;
|
||||||
|
--color-accent-hover: #818cf8;
|
||||||
|
|
||||||
|
--color-success: #22c55e;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-danger: #ef4444;
|
||||||
|
|
||||||
|
--color-text-primary: #f5f5f5;
|
||||||
|
--color-text-secondary: #a3a3a3;
|
||||||
|
--color-text-muted: #636363;
|
||||||
|
|
||||||
|
--color-border-subtle: #ffffff0d;
|
||||||
|
--color-border-default: #ffffff1a;
|
||||||
|
|
||||||
|
--radius-sm: 0.375rem;
|
||||||
|
--radius-md: 0.5rem;
|
||||||
|
--radius-lg: 0.75rem;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base ── */
|
||||||
|
body {
|
||||||
|
background-color: var(--color-surface-0);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Focus ── */
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Transitions ── */
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease,
|
||||||
|
box-shadow 150ms ease, opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ── */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-surface-3) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--color-surface-3);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Selection ── */
|
||||||
|
::selection {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,8 +110,11 @@ export default function JoinCodePage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center">
|
<main className="min-h-screen flex items-center justify-center bg-surface-0">
|
||||||
<p className="text-neutral-400">Загрузка...</p>
|
<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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -126,25 +129,44 @@ export default function JoinCodePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center px-4">
|
<main className="min-h-screen flex items-center justify-center px-4 bg-surface-0">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-md bg-surface-1 border border-border-default rounded-2xl shadow-xl p-8">
|
||||||
{room ? (
|
{room ? (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-1 text-center">{room.name}</h1>
|
{/* Room info header */}
|
||||||
<p className="text-neutral-400 text-sm text-center mb-8">
|
<div className="bg-surface-2 border border-border-default rounded-xl p-4 mb-6">
|
||||||
Код: {room.code}
|
<div className="flex items-center justify-between mb-2">
|
||||||
</p>
|
<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">
|
<form onSubmit={handleJoin} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-600/20 border border-red-600/50 text-red-400 px-4 py-3 rounded-lg text-sm">
|
<div className="bg-danger/10 border border-danger/20 text-danger px-4 py-3 rounded-lg text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="displayName" className="block text-sm text-neutral-400 mb-1">
|
<label htmlFor="displayName" className="block text-sm font-medium text-text-secondary mb-1.5">
|
||||||
Ваше имя
|
Ваше имя
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -153,14 +175,14 @@ export default function JoinCodePage() {
|
|||||||
required
|
required
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
|
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="Иван"
|
placeholder="Иван"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{room.hasPin && (
|
{room.hasPin && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="pin" className="block text-sm text-neutral-400 mb-1">
|
<label htmlFor="pin" className="block text-sm font-medium text-text-secondary mb-1.5">
|
||||||
PIN-код комнаты
|
PIN-код комнаты
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -169,7 +191,7 @@ export default function JoinCodePage() {
|
|||||||
required
|
required
|
||||||
value={pin}
|
value={pin}
|
||||||
onChange={(e) => setPin(e.target.value)}
|
onChange={(e) => setPin(e.target.value)}
|
||||||
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors text-center font-mono tracking-widest"
|
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"
|
placeholder="1234"
|
||||||
maxLength={8}
|
maxLength={8}
|
||||||
/>
|
/>
|
||||||
@@ -179,18 +201,34 @@ export default function JoinCodePage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={joining}
|
disabled={joining}
|
||||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors"
|
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 ? "Подключение..." : "Присоединиться"}
|
{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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center">
|
<div className="text-center py-4">
|
||||||
<p className="text-red-400 mb-4">{error || "Комната не найдена"}</p>
|
<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
|
<button
|
||||||
onClick={() => router.push("/join")}
|
onClick={() => router.push("/join")}
|
||||||
className="text-blue-500 hover:text-blue-400"
|
className="text-accent-hover hover:text-accent transition text-sm font-medium"
|
||||||
>
|
>
|
||||||
Ввести другой код
|
Ввести другой код
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function JoinPage() {
|
export default function JoinPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -15,27 +16,45 @@ export default function JoinPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center px-4">
|
<main className="min-h-screen flex items-center justify-center px-4 bg-surface-0">
|
||||||
<div className="w-full max-w-sm text-center">
|
<div className="w-full max-w-sm bg-surface-1 border border-border-default rounded-2xl shadow-xl p-8">
|
||||||
<h1 className="text-3xl font-bold mb-2">Присоединиться</h1>
|
<div className="text-center mb-8">
|
||||||
<p className="text-neutral-400 mb-8">Введите код комнаты</p>
|
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-accent/10 mb-4">
|
||||||
|
<svg className="w-6 h-6 text-accent" 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>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Присоединиться к лекции</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-1">Введите код приглашения</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<input
|
<div>
|
||||||
type="text"
|
<input
|
||||||
required
|
type="text"
|
||||||
value={code}
|
required
|
||||||
onChange={(e) => setCode(e.target.value)}
|
value={code}
|
||||||
className="w-full px-4 py-3 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors text-center text-lg font-mono tracking-widest"
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
placeholder="ABC123"
|
className="w-full bg-surface-2 border border-border-default rounded-lg px-4 py-4 text-white text-center text-lg font-mono tracking-widest uppercase placeholder:text-text-muted focus:border-accent focus:ring-1 focus:ring-accent outline-none transition"
|
||||||
/>
|
placeholder="ABC123"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition-colors"
|
className="w-full bg-accent hover:bg-accent-hover text-white rounded-lg px-4 py-3 font-medium transition"
|
||||||
>
|
>
|
||||||
Далее
|
Войти
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm">
|
||||||
|
<Link href="/" className="text-text-secondary hover:text-white transition inline-flex items-center gap-1">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||||
|
</svg>
|
||||||
|
На главную
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="ru">
|
<html lang="ru" className="dark">
|
||||||
<body className={`${inter.className} bg-neutral-950 text-white antialiased`}>
|
<body className={`${inter.className} bg-surface-0 text-text-primary antialiased`}>
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -34,19 +34,27 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center px-4">
|
<main className="min-h-screen flex items-center justify-center px-4 bg-surface-0">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm bg-surface-1 border border-border-default rounded-2xl shadow-xl p-8">
|
||||||
<h1 className="text-3xl font-bold mb-8 text-center">Вход</h1>
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-accent/10 mb-4">
|
||||||
|
<svg className="w-6 h-6 text-accent" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">LiveServer</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-1">Войдите в свой аккаунт</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-600/20 border border-red-600/50 text-red-400 px-4 py-3 rounded-lg text-sm">
|
<div className="bg-danger/10 border border-danger/20 text-danger px-4 py-3 rounded-lg text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm text-neutral-400 mb-1">
|
<label htmlFor="email" className="block text-sm font-medium text-text-secondary mb-1.5">
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -55,13 +63,13 @@ export default function LoginPage() {
|
|||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
|
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="you@example.com"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm text-neutral-400 mb-1">
|
<label htmlFor="password" className="block text-sm font-medium text-text-secondary mb-1.5">
|
||||||
Пароль
|
Пароль
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -70,7 +78,7 @@ export default function LoginPage() {
|
|||||||
required
|
required
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
|
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="********"
|
placeholder="********"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,15 +86,15 @@ export default function LoginPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
{loading ? "Вход..." : "Войти"}
|
{loading ? "Вход..." : "Войти"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-neutral-400">
|
<p className="mt-6 text-center text-sm text-text-muted">
|
||||||
Нет аккаунта?{" "}
|
Нет аккаунта?{" "}
|
||||||
<Link href="/register" className="text-blue-500 hover:text-blue-400">
|
<Link href="/register" className="text-accent-hover hover:text-accent transition">
|
||||||
Зарегистрироваться
|
Зарегистрироваться
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
106
src/app/page.tsx
106
src/app/page.tsx
@@ -1,28 +1,94 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
title: "Видеоконференции",
|
||||||
|
description: "HD видео и аудио с низкой задержкой на базе LiveKit. Поддержка демонстрации экрана и записи.",
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="w-6 h-6">
|
||||||
|
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.934a.5.5 0 0 0-.777-.416L16 11" />
|
||||||
|
<rect x="2" y="6" width="14" height="12" rx="2" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "AI-транскрипция",
|
||||||
|
description: "Автоматическая транскрипция лекций в реальном времени. Суммаризация и экспорт по завершении.",
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="w-6 h-6">
|
||||||
|
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
||||||
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||||
|
<line x1="12" x2="12" y1="19" y2="22" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Модерация",
|
||||||
|
description: "Lobby, PIN-коды, kick/ban, panic button. Полный контроль над безопасностью лекции.",
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="w-6 h-6">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10" />
|
||||||
|
<path d="m9 12 2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex flex-col items-center justify-center px-4">
|
<main className="min-h-screen flex flex-col">
|
||||||
<div className="text-center max-w-2xl">
|
{/* Hero */}
|
||||||
<h1 className="text-6xl font-bold tracking-tight mb-4">LiveServer</h1>
|
<section className="flex-1 flex flex-col items-center justify-center px-4 py-24">
|
||||||
<p className="text-xl text-neutral-400 mb-12">
|
<div className="text-center max-w-3xl">
|
||||||
Образовательная видеоконференц-платформа
|
<h1 className="text-5xl sm:text-7xl font-bold tracking-tight mb-6 bg-linear-to-r from-white via-accent to-accent-hover bg-clip-text text-transparent">
|
||||||
</p>
|
LiveServer
|
||||||
<div className="flex gap-4 justify-center">
|
</h1>
|
||||||
<Link
|
<p className="text-lg sm:text-xl text-text-secondary mb-12 max-w-xl mx-auto leading-relaxed">
|
||||||
href="/login"
|
Образовательная видеоконференц-платформа с AI-транскрипцией, модерацией и защитой лекций
|
||||||
className="px-8 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition-colors"
|
</p>
|
||||||
>
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
Войти
|
<Link
|
||||||
</Link>
|
href="/login"
|
||||||
<Link
|
className="inline-flex items-center justify-center px-8 py-3.5 bg-accent hover:bg-accent-hover text-white font-medium rounded-lg shadow-md hover:shadow-lg"
|
||||||
href="/join"
|
>
|
||||||
className="px-8 py-3 bg-neutral-800 hover:bg-neutral-700 rounded-lg font-medium transition-colors"
|
Войти
|
||||||
>
|
</Link>
|
||||||
Присоединиться к лекции
|
<Link
|
||||||
</Link>
|
href="/join"
|
||||||
|
className="inline-flex items-center justify-center px-8 py-3.5 border border-border-default hover:border-text-muted bg-surface-1 hover:bg-surface-2 text-text-primary font-medium rounded-lg"
|
||||||
|
>
|
||||||
|
Присоединиться к лекции
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<section className="px-4 pb-24">
|
||||||
|
<div className="max-w-5xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<div
|
||||||
|
key={feature.title}
|
||||||
|
className="p-6 rounded-xl bg-surface-1 border border-border-subtle hover:border-border-default shadow-sm hover:shadow-md transition-all"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-accent/10 text-accent flex items-center justify-center mb-4">
|
||||||
|
{feature.icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary mb-2">
|
||||||
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-text-secondary leading-relaxed">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t border-border-subtle py-6 text-center">
|
||||||
|
<p className="text-sm text-text-muted">LiveServer</p>
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,19 +35,27 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center px-4">
|
<main className="min-h-screen flex items-center justify-center px-4 bg-surface-0">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm bg-surface-1 border border-border-default rounded-2xl shadow-xl p-8">
|
||||||
<h1 className="text-3xl font-bold mb-8 text-center">Регистрация</h1>
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-accent/10 mb-4">
|
||||||
|
<svg className="w-6 h-6 text-accent" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Создать аккаунт</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-1">Присоединяйтесь к LiveServer</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-600/20 border border-red-600/50 text-red-400 px-4 py-3 rounded-lg text-sm">
|
<div className="bg-danger/10 border border-danger/20 text-danger px-4 py-3 rounded-lg text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="name" className="block text-sm text-neutral-400 mb-1">
|
<label htmlFor="name" className="block text-sm font-medium text-text-secondary mb-1.5">
|
||||||
Имя
|
Имя
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -56,13 +64,13 @@ export default function RegisterPage() {
|
|||||||
required
|
required
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
|
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="Иван Иванов"
|
placeholder="Иван Иванов"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm text-neutral-400 mb-1">
|
<label htmlFor="email" className="block text-sm font-medium text-text-secondary mb-1.5">
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -71,13 +79,13 @@ export default function RegisterPage() {
|
|||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
|
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="you@example.com"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm text-neutral-400 mb-1">
|
<label htmlFor="password" className="block text-sm font-medium text-text-secondary mb-1.5">
|
||||||
Пароль
|
Пароль
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -87,23 +95,24 @@ export default function RegisterPage() {
|
|||||||
minLength={8}
|
minLength={8}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
|
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="Минимум 8 символов"
|
placeholder="********"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-text-muted mt-1.5">Минимум 8 символов</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
{loading ? "Регистрация..." : "Создать аккаунт"}
|
{loading ? "Регистрация..." : "Создать аккаунт"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-neutral-400">
|
<p className="mt-6 text-center text-sm text-text-muted">
|
||||||
Уже есть аккаунт?{" "}
|
Уже есть аккаунт?{" "}
|
||||||
<Link href="/login" className="text-blue-500 hover:text-blue-400">
|
<Link href="/login" className="text-accent-hover hover:text-accent transition">
|
||||||
Войти
|
Войти
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export default function RoomPage() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [showChat, setShowChat] = useState(false);
|
const [showChat, setShowChat] = useState(false);
|
||||||
const [showLobby, setShowLobby] = useState(false);
|
const [showLobby, setShowLobby] = useState(false);
|
||||||
|
const [showModeration, setShowModeration] = useState(false);
|
||||||
const [handRaised, setHandRaised] = useState(false);
|
const [handRaised, setHandRaised] = useState(false);
|
||||||
|
|
||||||
const userId = session?.user?.id ?? null;
|
const userId = session?.user?.id ?? null;
|
||||||
@@ -110,63 +111,114 @@ export default function RoomPage() {
|
|||||||
}
|
}
|
||||||
}, [room, handRaised, userId]);
|
}, [room, handRaised, userId]);
|
||||||
|
|
||||||
|
const sidebarOpen = showChat || showLobby || showModeration;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center">
|
<main className="min-h-screen flex items-center justify-center bg-surface-0">
|
||||||
<p className="text-neutral-400">Загрузка комнаты...</p>
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-10 h-10 border-3 border-surface-3 border-t-accent rounded-full animate-spin" />
|
||||||
|
<p className="text-text-secondary text-sm font-medium">Подключение...</p>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !token) {
|
if (error || !token) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center">
|
<main className="min-h-screen flex items-center justify-center bg-surface-0">
|
||||||
<div className="text-center">
|
<div className="bg-surface-1 border border-border-default rounded-2xl px-8 py-10 max-w-sm w-full text-center">
|
||||||
<p className="text-red-400 mb-4">{error || "Нет токена для подключения"}</p>
|
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-danger/10 flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-danger" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-white font-semibold text-lg mb-2">Ошибка подключения</h2>
|
||||||
|
<p className="text-text-secondary text-sm">{error || "Нет токена для подключения"}</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-neutral-950">
|
<div className="h-screen flex flex-col bg-surface-0">
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<header className="flex items-center justify-between px-4 py-2 bg-neutral-900 border-b border-neutral-800 shrink-0">
|
<header className="flex items-center justify-between px-4 h-14 bg-surface-1 border-b border-border-default shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="font-semibold">{room?.name ?? "Комната"}</h1>
|
<h1 className="text-white font-semibold text-[15px]">{room?.name ?? "Комната"}</h1>
|
||||||
{room && (
|
{room && (
|
||||||
<span className="text-xs text-neutral-400 font-mono bg-neutral-800 px-2 py-0.5 rounded">
|
<span className="text-[11px] text-text-muted font-mono bg-surface-2 px-2 py-0.5 rounded-md border border-border-subtle">
|
||||||
{room.code}
|
{room.code}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5">
|
||||||
{room?.webinarMode && !isHost && (
|
{room?.webinarMode && !isHost && (
|
||||||
<button
|
<button
|
||||||
onClick={handleRaiseHand}
|
onClick={handleRaiseHand}
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
handRaised
|
handRaised
|
||||||
? "bg-yellow-600 hover:bg-yellow-700"
|
? "bg-warning/20 text-warning border border-warning/30"
|
||||||
: "bg-neutral-800 hover:bg-neutral-700"
|
: "bg-surface-2 hover:bg-surface-3 text-text-secondary hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{handRaised ? "Опустить руку" : "Поднять руку"}
|
<span className="flex items-center gap-1.5">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11" />
|
||||||
|
</svg>
|
||||||
|
{handRaised ? "Опустить руку" : "Поднять руку"}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isHost && room?.lobbyEnabled && (
|
{isHost && room?.lobbyEnabled && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowLobby((v) => !v)}
|
onClick={() => setShowLobby((v) => !v)}
|
||||||
className="px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 rounded-lg text-sm font-medium transition-colors"
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
showLobby
|
||||||
|
? "bg-accent/20 text-accent-hover border border-accent/30"
|
||||||
|
: "bg-surface-2 hover:bg-surface-3 text-text-secondary hover:text-white"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Зал ожидания
|
<span className="flex items-center gap-1.5">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||||
|
</svg>
|
||||||
|
Зал ожидания
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowChat((v) => !v)}
|
onClick={() => setShowChat((v) => !v)}
|
||||||
className="px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 rounded-lg text-sm font-medium transition-colors"
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
showChat
|
||||||
|
? "bg-accent/20 text-accent-hover border border-accent/30"
|
||||||
|
: "bg-surface-2 hover:bg-surface-3 text-text-secondary hover:text-white"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Чат
|
<span className="flex items-center gap-1.5">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
||||||
|
</svg>
|
||||||
|
Чат
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{isHost && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModeration((v) => !v)}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
showModeration
|
||||||
|
? "bg-accent/20 text-accent-hover border border-accent/30"
|
||||||
|
: "bg-surface-2 hover:bg-surface-3 text-text-secondary hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||||
|
</svg>
|
||||||
|
Модерация
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -183,14 +235,18 @@ export default function RoomPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebars — inside LiveKitRoom for useParticipants() context */}
|
{/* Sidebars — inside LiveKitRoom for useParticipants() context */}
|
||||||
{(showChat || showLobby || isHost) && (
|
{sidebarOpen && (
|
||||||
<aside className="w-80 shrink-0 border-l border-neutral-800 flex flex-col bg-neutral-900">
|
<aside className="w-80 shrink-0 border-l border-border-default flex flex-col bg-surface-1 transition-all">
|
||||||
{isHost && showLobby && room && (
|
{isHost && showLobby && room && (
|
||||||
<div className="border-b border-neutral-800">
|
<div className="border-b border-border-default">
|
||||||
<LobbyManager roomId={room.id} />
|
<LobbyManager roomId={room.id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isHost && room && <ModerationPanel roomId={room.id} />}
|
{isHost && showModeration && room && (
|
||||||
|
<div className="border-b border-border-default">
|
||||||
|
<ModerationPanel roomId={room.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showChat && room && (
|
{showChat && room && (
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<ChatPanel
|
<ChatPanel
|
||||||
|
|||||||
@@ -56,32 +56,44 @@ export default function LobbyManager({ roomId }: LobbyManagerProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="text-sm font-semibold text-neutral-400 uppercase tracking-wider mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
Зал ожидания ({entries.length})
|
<h3 className="text-sm font-semibold text-white">Зал ожидания</h3>
|
||||||
</h3>
|
{entries.length > 0 && (
|
||||||
|
<span className="text-[11px] font-medium text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
|
||||||
|
{entries.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<p className="text-sm text-neutral-500">Никто не ожидает</p>
|
<p className="text-sm text-text-muted py-2">Никто не ожидает</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className="flex items-center justify-between bg-neutral-800 rounded-lg px-3 py-2"
|
className="flex items-center justify-between bg-surface-2 rounded-lg px-3 py-2.5 hover:bg-surface-3 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-sm truncate mr-2">{entry.displayName}</span>
|
<div className="flex items-center gap-2 min-w-0 mr-2">
|
||||||
|
<div className="w-7 h-7 rounded-full bg-surface-3 flex items-center justify-center shrink-0">
|
||||||
|
<span className="text-xs font-medium text-text-secondary">
|
||||||
|
{entry.displayName.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-text-primary truncate">{entry.displayName}</span>
|
||||||
|
</div>
|
||||||
<div className="flex gap-1 shrink-0">
|
<div className="flex gap-1 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction(entry, "APPROVED")}
|
onClick={() => handleAction(entry, "APPROVED")}
|
||||||
disabled={acting === entry.id}
|
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"
|
className="px-2.5 py-1 text-xs font-medium text-success bg-success/10 hover:bg-success/20 border border-success/20 rounded-md disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
Принять
|
Принять
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction(entry, "REJECTED")}
|
onClick={() => handleAction(entry, "REJECTED")}
|
||||||
disabled={acting === entry.id}
|
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"
|
className="px-2.5 py-1 text-xs font-medium text-danger bg-danger/10 hover:bg-danger/20 border border-danger/20 rounded-md disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
Отклонить
|
Отклонить
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -50,11 +50,11 @@ export default function WaitingRoom({
|
|||||||
|
|
||||||
if (rejected) {
|
if (rejected) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center px-4">
|
<main className="min-h-screen flex items-center justify-center px-4 bg-surface-0">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-600/20 flex items-center justify-center">
|
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-danger/10 flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
className="w-8 h-8 text-red-400"
|
className="w-10 h-10 text-danger"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -67,26 +67,28 @@ export default function WaitingRoom({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold mb-2">Вам отказано в доступе</h2>
|
<h2 className="text-2xl font-bold text-white mb-2">Доступ отклонён</h2>
|
||||||
<p className="text-neutral-400">Хост отклонил ваш запрос на вход</p>
|
<p className="text-text-secondary text-base">Хост отклонил ваш запрос</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center px-4">
|
<main className="min-h-screen flex items-center justify-center px-4 bg-surface-0">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="w-16 h-16 mx-auto mb-6 relative">
|
<div className="w-28 h-28 mx-auto mb-8 relative flex items-center justify-center">
|
||||||
<div className="absolute inset-0 rounded-full bg-blue-600/20 animate-ping" />
|
{/* Outer pulsing ring */}
|
||||||
<div className="relative w-16 h-16 rounded-full bg-blue-600/30 flex items-center justify-center">
|
<div className="absolute inset-0 rounded-full border-2 border-accent/20 animate-ping" />
|
||||||
<div className="w-8 h-8 rounded-full bg-blue-600 animate-pulse" />
|
{/* Middle ring */}
|
||||||
</div>
|
<div className="absolute inset-3 rounded-full border-2 border-accent/30 animate-pulse" />
|
||||||
|
{/* Inner ring */}
|
||||||
|
<div className="absolute inset-6 rounded-full border-2 border-accent/40" />
|
||||||
|
{/* Center dot */}
|
||||||
|
<div className="relative w-8 h-8 rounded-full bg-accent animate-pulse shadow-lg shadow-accent/30" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold mb-2">Ожидание подтверждения хоста...</h2>
|
<h2 className="text-2xl font-bold text-white mb-2">Ожидание подтверждения</h2>
|
||||||
<p className="text-neutral-400 text-sm">
|
<p className="text-text-secondary text-base">Хост скоро вас примет</p>
|
||||||
Хост скоро подтвердит ваш вход в комнату
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ChatPanelProps {
|
|||||||
|
|
||||||
type ChatMessage = {
|
type ChatMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
content: string;
|
content: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -80,6 +81,7 @@ export default function ChatPanel({ roomId, sessionId, senderName }: ChatPanelPr
|
|||||||
// Optimistic UI — show message immediately
|
// Optimistic UI — show message immediately
|
||||||
const optimisticMsg: ChatMessage = {
|
const optimisticMsg: ChatMessage = {
|
||||||
id: `temp-${Date.now()}`,
|
id: `temp-${Date.now()}`,
|
||||||
|
sessionId,
|
||||||
senderName,
|
senderName,
|
||||||
content,
|
content,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@@ -110,46 +112,79 @@ export default function ChatPanel({ roomId, sessionId, senderName }: ChatPanelPr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTime(dateStr: string) {
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="px-4 py-3 border-b border-neutral-800">
|
<div className="px-4 py-3 border-b border-border-default flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-neutral-400 uppercase tracking-wider">
|
<h3 className="text-sm font-semibold text-white">Чат</h3>
|
||||||
Чат
|
{messages.length > 0 && (
|
||||||
</h3>
|
<span className="text-[11px] text-text-muted bg-surface-2 px-1.5 py-0.5 rounded-md font-medium">
|
||||||
|
{messages.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<p className="text-sm text-neutral-500 text-center mt-8">
|
<div className="flex flex-col items-center justify-center mt-16 gap-2">
|
||||||
Сообщений пока нет
|
<svg className="w-8 h-8 text-surface-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
</p>
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
||||||
)}
|
</svg>
|
||||||
{messages.map((msg) => (
|
<p className="text-sm text-text-muted">Сообщений пока нет</p>
|
||||||
<div key={msg.id}>
|
|
||||||
<p className="text-xs text-neutral-500 mb-0.5">{msg.senderName}</p>
|
|
||||||
<p className="text-sm bg-neutral-800 rounded-lg px-3 py-2 inline-block max-w-full break-words">
|
|
||||||
{msg.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
{messages.map((msg) => {
|
||||||
|
const isOwn = msg.sessionId === sessionId;
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className={`flex flex-col ${isOwn ? "items-end" : "items-start"}`}>
|
||||||
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
|
<span className="text-[11px] text-text-muted font-medium">
|
||||||
|
{isOwn ? "Вы" : msg.senderName}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-muted/60">
|
||||||
|
{formatTime(msg.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`rounded-xl px-3 py-2 text-sm max-w-[85%] wrap-break-word ${
|
||||||
|
isOwn
|
||||||
|
? "bg-accent text-white rounded-tr-sm"
|
||||||
|
: "bg-surface-2 text-text-primary rounded-tl-sm"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSend} className="p-3 border-t border-neutral-800">
|
<form onSubmit={handleSend} className="p-3 border-t border-border-default">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
placeholder="Сообщение..."
|
placeholder="Написать сообщение..."
|
||||||
className="flex-1 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm focus:outline-none focus:border-blue-600 transition-colors"
|
className="flex-1 px-4 py-2.5 bg-surface-2 border-none rounded-lg text-sm text-white placeholder-text-muted focus:outline-none focus:ring-1 focus:ring-accent/50 transition-all"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={sending || !input.trim()}
|
disabled={sending || !input.trim()}
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-sm font-medium transition-colors"
|
className="px-3 py-2.5 bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed rounded-lg text-sm font-medium text-white transition-colors"
|
||||||
>
|
>
|
||||||
Отправить
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -32,48 +32,58 @@ export default function ModerationPanel({ roomId }: ModerationPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="text-sm font-semibold text-neutral-400 uppercase tracking-wider mb-3">
|
<h3 className="text-sm font-semibold text-white mb-4">Модерация</h3>
|
||||||
Модерация
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => moderate("mute_all")}
|
onClick={() => moderate("mute_all")}
|
||||||
disabled={acting === "mute_all"}
|
disabled={acting === "mute_all"}
|
||||||
className="w-full py-2 bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors mb-4"
|
className="w-full py-2.5 bg-danger/10 hover:bg-danger/20 text-danger border border-danger/20 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors mb-5 flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
Выключить микрофон у всех
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 19l-7-7m0 0l-7-7m7 7l7-7m-7 7l-7 7" />
|
||||||
|
</svg>
|
||||||
|
Выключить все микрофоны
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<p className="text-xs text-neutral-500">
|
<p className="text-xs text-text-muted font-medium uppercase tracking-wider mb-2">
|
||||||
Участники ({participants.length})
|
Участники ({participants.length})
|
||||||
</p>
|
</p>
|
||||||
{participants.map((p) => (
|
<div className="space-y-1.5">
|
||||||
<div
|
{participants.map((p) => (
|
||||||
key={p.identity}
|
<div
|
||||||
className="flex items-center justify-between bg-neutral-800 rounded-lg px-3 py-2"
|
key={p.identity}
|
||||||
>
|
className="flex items-center justify-between bg-surface-2 rounded-lg px-3 py-2.5 group hover:bg-surface-3 transition-colors"
|
||||||
<span className="text-sm truncate mr-2">
|
>
|
||||||
{p.name || p.identity}
|
<div className="flex items-center gap-2 min-w-0 mr-2">
|
||||||
</span>
|
<div className="w-7 h-7 rounded-full bg-surface-3 flex items-center justify-center shrink-0">
|
||||||
<div className="flex gap-1 shrink-0">
|
<span className="text-xs font-medium text-text-secondary">
|
||||||
<button
|
{(p.name || p.identity).charAt(0).toUpperCase()}
|
||||||
onClick={() => moderate("kick", p.identity)}
|
</span>
|
||||||
disabled={acting === p.identity}
|
</div>
|
||||||
className="px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 rounded transition-colors"
|
<span className="text-sm text-text-primary truncate">
|
||||||
>
|
{p.name || p.identity}
|
||||||
Кик
|
</span>
|
||||||
</button>
|
</div>
|
||||||
<button
|
<div className="flex gap-1 shrink-0">
|
||||||
onClick={() => moderate("ban", p.identity)}
|
<button
|
||||||
disabled={acting === p.identity}
|
onClick={() => moderate("kick", p.identity)}
|
||||||
className="px-2 py-1 text-xs bg-red-600/20 text-red-400 hover:bg-red-600/30 rounded transition-colors"
|
disabled={acting === p.identity}
|
||||||
>
|
className="px-2.5 py-1 text-xs text-text-secondary bg-surface-1 hover:bg-surface-3 hover:text-white border border-border-default rounded-md transition-colors disabled:opacity-50"
|
||||||
Бан
|
>
|
||||||
</button>
|
Кик
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => moderate("ban", p.identity)}
|
||||||
|
disabled={acting === p.identity}
|
||||||
|
className="px-2.5 py-1 text-xs text-danger bg-danger/10 hover:bg-danger/20 border border-danger/20 rounded-md transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Бан
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function getTrustedOrigins(): string[] {
|
|||||||
for (const host of hosts) {
|
for (const host of hosts) {
|
||||||
for (let port = 3000; port <= 3010; port++) {
|
for (let port = 3000; port <= 3010; port++) {
|
||||||
origins.push(`http://${host}:${port}`);
|
origins.push(`http://${host}:${port}`);
|
||||||
|
origins.push(`https://${host}:${port}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user