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:
2026-03-24 12:14:49 +03:00
parent 848540cfbf
commit f6d3f37a5f
17 changed files with 766 additions and 314 deletions

View File

@@ -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",

View File

@@ -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
} }
# ============================================================ # ============================================================

View File

@@ -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"
> >
&larr; Назад к комнатам <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>
); );

View File

@@ -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>
); );
} }

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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}`);
} }
} }