Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AdminConferencesTab } from '@/components/admin/AdminConferencesTab'
import { AdminUsersTab } from '@/components/admin/AdminUsersTab'
import { AdminTeamsTab } from '@/components/admin/AdminTeamsTab'
import { AdminSettingsTab } from '@/components/admin/AdminSettingsTab'
import { getHealth } from '@/api/health'
import '@/styles/lobby.css'
import '@/styles/calendar.css'
import '@/styles/admin.css'
type AdminTab = 'conferences' | 'users' | 'teams' | 'settings'
const TABS: { value: AdminTab; label: string }[] = [
{ value: 'conferences', label: 'Конференции' },
{ value: 'users', label: 'Пользователи' },
{ value: 'teams', label: 'Команды' },
{ value: 'settings', label: 'Настройки' },
]
/**
* Администрирование VidConf: управление конференциями, доступом
* пользователей и настройками инстанса. Доступ ограничен ролью `admin`
* (route-guard — см. `RequireAdmin` в App.tsx).
*
* Вкладки «Команды» и «Настройки» отсутствуют в макете
* `design/mockups/admin.html` — реализованы по общему паттерну
* `section-tab`/`table-card`/`toolbar-row` того же макета и DESIGN_SYSTEM.md.
*/
export function AdminPage() {
const [tab, setTab] = useState<AdminTab>('conferences')
// Версия инстанса для футера — поле `version` в `/api/health`;
// если его нет в ответе, футер версию просто не показывает
// (см. `HealthOut.version`).
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, retry: false })
return (
<div className="admin-page page-shell">
<ShellTopbar />
<main className="page-main">
<div className="page-head">
<div>
<h1>Администрирование</h1>
<p>Управление конференциями, доступом и пользователями VidConf</p>
</div>
</div>
<div className="section-tabs">
{TABS.map((t) => (
<button
key={t.value}
type="button"
className={`section-tab${tab === t.value ? ' is-active' : ''}`}
onClick={() => setTab(t.value)}
>
{t.label}
</button>
))}
</div>
{tab === 'conferences' && <AdminConferencesTab />}
{tab === 'users' && <AdminUsersTab />}
{tab === 'teams' && <AdminTeamsTab />}
{tab === 'settings' && <AdminSettingsTab />}
<footer className="admin-footer">
VidConf{health?.version ? ` · v${health.version}` : ''}
</footer>
</main>
</div>
)
}

View File

@@ -0,0 +1,117 @@
import { useCallback, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Plus } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
import { getCalendarOccurrences, getMyConferences, type ConferenceOut, type OccurrenceOut } from '@/api/conferences'
import { pluralizeConferences } from '@/lib/pluralize'
import '@/styles/lobby.css'
import '@/styles/calendar.css'
interface VisibleRange {
fromIso: string
toIso: string
}
/** Стабильный дефолт для пропса `occurrences` `ConferenceCalendar` (пока `useQuery` не загрузил данные) — см. комментарий в ConferenceCalendar.tsx про identity пропсов FullCalendar. */
const EMPTY_OCCURRENCES: OccurrenceOut[] = []
/**
* Календарь-менеджер запланированных/закреплённых конференций
* (см. design/mockups/calendar.html).
*
* Редактирование в сайдбаре доступно только для «своих» вхождений — тех,
* что присутствуют в `/conferences/my` (закреплённые и предстоящие разовые
* владельца; там же полная модель — recurrence/duration, которых нет в
* `OccurrenceOut` календарной выборки). Остальные вхождения по клику
* открывают лёгкий информационный диалог с входом и ссылкой на «Мои
* конференции» — согласованное отклонение от макета, вызванное отсутствием
* в контракте эндпоинта получения одной конференции по id.
*/
export function CalendarPage() {
const [view, setView] = useState<CalendarViewMode>('dayGridWeek')
const [range, setRange] = useState<VisibleRange | null>(null)
const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null)
const [occurrenceDialogTarget, setOccurrenceDialogTarget] = useState<OccurrenceOut | null>(null)
const { data: occurrences, isLoading: occurrencesLoading } = useQuery({
queryKey: ['conferences', 'calendar', range?.fromIso, range?.toIso],
queryFn: () => getCalendarOccurrences(range!.fromIso, range!.toIso),
enabled: !!range,
})
const { data: myConferences } = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences })
const myConferenceById = useMemo(() => new Map((myConferences ?? []).map((c) => [c.id, c])), [myConferences])
const handleOccurrenceClick = useCallback(
(occurrence: OccurrenceOut) => {
// `/conferences/my` может содержать и конференции, куда пользователь
// только приглашён участником (не организатор) — редактирование
// открываем только владельцу, иначе — тот же
// информационный диалог, что и для чужих вхождений.
const owned = myConferenceById.get(occurrence.conference_id)
if (owned?.is_owner) {
setEditingConference(owned)
} else {
setOccurrenceDialogTarget(occurrence)
}
},
[myConferenceById],
)
// Бэйл-аут по значению (не по ссылке): FullCalendar вызывает `datesSet`
// повторно всякий раз, когда меняется identity его пропсов (см. подробный
// комментарий в ConferenceCalendar.tsx). Если диапазон фактически не
// изменился, `setRange` возвращает тот же объект — React пропускает
// ре-рендер (Object.is-бэйлаут), и цикл не запускается даже если
// `datesSet` дёрнется ещё раз вхолостую.
const handleRangeChange = useCallback((fromIso: string, toIso: string) => {
setRange((prev) => (prev && prev.fromIso === fromIso && prev.toIso === toIso ? prev : { fromIso, toIso }))
}, [])
return (
<div className="page-shell">
<ShellTopbar />
<main className="page-main">
<div className="page-head">
<div>
<h1>Календарь конференций</h1>
<p>
{occurrences?.length ?? 0} {pluralizeConferences(occurrences?.length ?? 0)} в выбранном периоде запланируйте
разовую встречу или закрепите постоянную конференцию с повторением
</p>
</div>
<button type="button" className="btn btn-primary" onClick={() => setEditingConference(null)}>
<Plus style={{ width: 16, height: 16 }} aria-hidden="true" />
Новая конференция
</button>
</div>
<div className="layout-grid">
<ConferenceCalendar
view={view}
onViewChange={setView}
occurrences={occurrences ?? EMPTY_OCCURRENCES}
isLoading={occurrencesLoading}
onRangeChange={handleRangeChange}
onOccurrenceClick={handleOccurrenceClick}
/>
<ConferenceFormCard
key={editingConference?.id ?? 'create'}
conference={editingConference ?? undefined}
onSaved={() => setEditingConference(null)}
onDeleted={() => setEditingConference(null)}
onCancelEdit={() => setEditingConference(null)}
/>
</div>
</main>
{occurrenceDialogTarget && (
<ConferenceOccurrenceDialog occurrence={occurrenceDialogTarget} onClose={() => setOccurrenceDialogTarget(null)} />
)}
</div>
)
}

View File

@@ -0,0 +1,414 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { AlertCircle, ArrowRight, CheckCircle2, Eye, EyeOff, Hash, Link2, Lock, Mail, User, Video } from 'lucide-react'
import {
guestJoinConference,
joinConference,
resolveConference,
type ConferenceJoinData,
} from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle'
import { parseJoinQuery } from '@/lib/parseJoinQuery'
import '@/styles/join.css'
type Step = 'input' | 'guest-info' | 'password' | 'connecting' | 'ended'
interface ResolvedTarget {
id: string
title: string | null
isClosed: boolean
requiresPassword: boolean
/** Нормализованный запрос пользователя (slug/номер) — используется как параметр `/room/:slug`. */
query: string
}
/**
* Подключение к конференции (см. design/mockups/join.html) — три состояния
* одного флоу: (а) ввод ссылки/номера, (б) «представиться» для гостя,
* (в) пароль закрытой конференции. Экран публичный — доступен без сессии
* (гость входит без аккаунта), поэтому не завёрнут в `RequireAuth`.
*
* Маршруты: `/join` (ручной ввод, опционально `?q=` — например, после
* редиректа из RoomPage при потере join-состояния) и `/j/:slug` (прямая
* ссылка-приглашение — резолвится автоматически при заходе).
*
* Авторизованный пользователь пропускает «представиться»: после резолва либо
* сразу входит (если конференция открыта), либо сразу видит экран пароля
* (если закрыта). Гость всегда проходит «представиться»; если конференция
* закрыта, экран пароля показывается вторым шагом, после чего оба поля
* (имя/email + пароль) уходят одним запросом `guest-join`.
*/
export function JoinPage() {
const navigate = useNavigate()
const { slug: slugParam } = useParams<{ slug?: string }>()
const [searchParams] = useSearchParams()
const { status } = useAuth()
const isAuthenticated = status === 'authenticated'
const initialQuery = slugParam ?? searchParams.get('q') ?? ''
const [query, setQuery] = useState(initialQuery)
const [resolving, setResolving] = useState(false)
const [resolveError, setResolveError] = useState<string | null>(null)
const [step, setStep] = useState<Step>('input')
const [target, setTarget] = useState<ResolvedTarget | null>(null)
const [guestName, setGuestName] = useState('')
const [guestEmail, setGuestEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [invalidPassword, setInvalidPassword] = useState(false)
const [joining, setJoining] = useState(false)
const autoResolvedRef = useRef(false)
// Прямая ссылка (/j/:slug) или ?q= из редиректа — резолвим сразу, не
// дожидаясь клика по «Продолжить», но только после того как известен
// статус сессии (нужен для выбора «гость / авторизованный» флоу).
useEffect(() => {
if (status === 'loading' || autoResolvedRef.current || !initialQuery) return
autoResolvedRef.current = true
void handleResolve(initialQuery)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status])
async function handleResolve(rawQuery: string) {
const normalized = parseJoinQuery(rawQuery)
if (!normalized) return
setResolveError(null)
setResolving(true)
try {
const info = await resolveConference(normalized)
// Завершённая конференция (ADR-001) — backend отдаёт минимальный ответ
// без is_closed/requires_password: сразу показываем «завершена», не
// дожидаясь шага «представиться»/пароль и попытки join (которая всё
// равно вернёт 410).
if (info.status === 'ended') {
setTarget({ id: info.id, title: info.title, isClosed: false, requiresPassword: false, query: normalized })
setStep('ended')
return
}
const nextTarget: ResolvedTarget = {
id: info.id,
title: info.title,
isClosed: info.is_closed ?? false,
requiresPassword: info.requires_password ?? false,
query: normalized,
}
setTarget(nextTarget)
if (nextTarget.requiresPassword) {
setStep('password')
} else if (isAuthenticated) {
setStep('connecting')
await performJoin(nextTarget, {})
} else {
setStep('guest-info')
}
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
setResolveError('Конференция не найдена — проверьте ссылку или номер')
} else {
setResolveError('Не удалось найти конференцию. Попробуйте ещё раз')
}
setStep('input')
} finally {
setResolving(false)
}
}
async function performJoin(
resolved: ResolvedTarget,
opts: { password?: string; guestName?: string; guestEmail?: string },
) {
setJoining(true)
setInvalidPassword(false)
try {
let data: ConferenceJoinData
if (isAuthenticated) {
data = await joinConference(resolved.id, opts.password ? { password: opts.password } : undefined)
} else {
data = await guestJoinConference(resolved.id, {
display_name: opts.guestName ?? guestName.trim(),
email: (opts.guestEmail ?? guestEmail).trim() || undefined,
password: opts.password,
})
}
navigate(`/room/${encodeURIComponent(resolved.query)}`, {
replace: true,
state: {
livekitUrl: data.livekit_url,
token: data.token,
roomName: data.room_name,
conferenceId: data.conference_id,
chatEnabled: data.chat_enabled,
},
})
} catch (err) {
if (err instanceof ApiError && err.status === 403 && errorDetail(err) === 'password_required') {
setStep('password')
} else if (err instanceof ApiError && err.status === 403 && errorDetail(err) === 'invalid_password') {
setInvalidPassword(true)
setStep('password')
} else if (err instanceof ApiError && err.status === 410) {
setStep('ended')
} else {
setResolveError('Не удалось подключиться к конференции. Попробуйте ещё раз позже')
setStep('input')
}
} finally {
setJoining(false)
}
}
function handleInputSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
void handleResolve(query)
}
function handleGuestInfoSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!target || !guestName.trim()) return
if (target.requiresPassword) {
setStep('password')
} else {
void performJoin(target, {})
}
}
function handlePasswordSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!target || !password) return
void performJoin(target, { password })
}
function resetToInput() {
setResolveError(null)
setInvalidPassword(false)
setStep('input')
}
if (step === 'connecting') {
return (
<div className="app-loader" role="status" aria-live="polite">
Подключаемся к конференции
</div>
)
}
return (
<div className="join-shell">
<header className="join-topbar">
<Link to="/lobby" className="brand-mark">
<LogoMark /> VidConf
</Link>
<ThemeToggle />
</header>
<main className="join-main">
{step === 'input' && (
<section className="screen-block">
<div className="flow-card">
<div className="flow-icon" aria-hidden="true">
<Link2 className="icon" aria-hidden="true" />
</div>
<h1>Подключиться к конференции</h1>
<p className="flow-sub">Вставьте ссылку-приглашение или введите номер конференции</p>
<form onSubmit={handleInputSubmit}>
<div className={`field${resolveError ? ' has-error' : ''}`}>
<label htmlFor="join-input">Ссылка или номер</label>
<div className="input-wrap">
<Hash className="icon" aria-hidden="true" />
<input
id="join-input"
type="text"
placeholder="vidconf.ru/j/... или 8842 104"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
</div>
{resolveError ? (
<p className="error-msg">
<AlertCircle style={{ width: 16, height: 16 }} aria-hidden="true" /> {resolveError}
</p>
) : (
<p className="field-hint">Найдите номер или ссылку в приглашении организатора</p>
)}
</div>
<button type="submit" className="btn btn-primary" disabled={!query.trim() || resolving}>
{resolving ? 'Ищем конференцию…' : 'Продолжить'}
{!resolving && <ArrowRight style={{ width: 18, height: 18 }} aria-hidden="true" />}
</button>
</form>
<Link to="/lobby" className="link-back">
Назад в лобби
</Link>
</div>
</section>
)}
{step === 'guest-info' && target && (
<section className="screen-block">
<div className="flow-card">
<div className="target-conf">
<span className="icon-wrap" aria-hidden="true">
{target.isClosed ? <Lock className="icon" aria-hidden="true" /> : <Video className="icon" aria-hidden="true" />}
</span>
<div>
<strong>{target.title ?? 'Конференция без названия'}</strong>
<span> {target.query}</span>
</div>
</div>
<div className="flow-icon" aria-hidden="true">
<User className="icon" aria-hidden="true" />
</div>
<h1>Как вас зовут?</h1>
<p className="flow-sub">У вас нет аккаунта VidConf представьтесь, чтобы войти как гость</p>
<form onSubmit={handleGuestInfoSubmit}>
<div className="field">
<label htmlFor="guest-name">
Имя<span className="field-optional">· обязательно</span>
</label>
<div className="input-wrap">
<User className="icon" aria-hidden="true" />
<input
id="guest-name"
type="text"
placeholder="Как к вам обращаться"
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
autoFocus
/>
</div>
</div>
<div className="field">
<label htmlFor="guest-email">
Email<span className="field-optional">· необязательно</span>
</label>
<div className="input-wrap">
<Mail className="icon" aria-hidden="true" />
<input
id="guest-email"
type="email"
placeholder="you@mail.ru"
value={guestEmail}
onChange={(e) => setGuestEmail(e.target.value)}
/>
</div>
<p className="field-hint">Укажите, если хотите получить саммари конференции на почту</p>
</div>
<button type="submit" className="btn btn-primary" disabled={!guestName.trim() || joining}>
{joining ? 'Входим…' : 'Войти в конференцию'}
{!joining && <ArrowRight style={{ width: 18, height: 18 }} aria-hidden="true" />}
</button>
</form>
<button type="button" className="link-back" onClick={resetToInput}>
Назад
</button>
</div>
</section>
)}
{step === 'password' && target && (
<section className="screen-block">
<div className="flow-card">
<div className="target-conf">
<span className="icon-wrap" aria-hidden="true">
<Lock className="icon" aria-hidden="true" />
</span>
<div>
<strong>{target.title ?? 'Конференция без названия'}</strong>
<span> {target.query} · закрытая</span>
</div>
</div>
<div className="flow-icon" aria-hidden="true">
<Lock className="icon" aria-hidden="true" />
</div>
<h1>Введите пароль</h1>
<p className="flow-sub">Эта конференция закрыта доступ только по паролю от организатора</p>
<form onSubmit={handlePasswordSubmit}>
<div className={`field${invalidPassword ? ' has-error' : ''}`}>
<label htmlFor="join-pass">Пароль конференции</label>
<div className="input-wrap">
<Lock className="icon" aria-hidden="true" />
<input
id="join-pass"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
value={password}
onChange={(e) => {
setPassword(e.target.value)
setInvalidPassword(false)
}}
autoFocus
/>
<button
type="button"
className="toggle-eye"
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? <EyeOff className="icon" aria-hidden="true" /> : <Eye className="icon" aria-hidden="true" />}
</button>
</div>
{invalidPassword && (
<p className="error-msg">
<AlertCircle style={{ width: 16, height: 16 }} aria-hidden="true" /> Неверный пароль, попробуйте ещё раз
</p>
)}
</div>
<button type="submit" className="btn btn-primary" disabled={!password || joining}>
{joining ? 'Входим…' : 'Войти'}
{!joining && <ArrowRight style={{ width: 18, height: 18 }} aria-hidden="true" />}
</button>
</form>
<button type="button" className="link-back" onClick={resetToInput}>
Назад
</button>
</div>
</section>
)}
{step === 'ended' && (
<section className="screen-block">
<div className="flow-card">
{target && (
<div className="target-conf">
<span className="icon-wrap" aria-hidden="true">
<CheckCircle2 className="icon" aria-hidden="true" />
</span>
<div>
<strong>{target.title ?? 'Конференция без названия'}</strong>
<span> {target.query} · завершена</span>
</div>
</div>
)}
<div className="flow-icon" aria-hidden="true">
<CheckCircle2 className="icon" aria-hidden="true" />
</div>
<h1>Конференция завершена</h1>
<p className="flow-sub">Эта конференция уже завершилась и недоступна для повторного входа</p>
<button type="button" className="btn btn-primary" onClick={resetToInput}>
Ввести другую ссылку или номер
</button>
<Link to="/lobby" className="link-back">
Назад в лобби
</Link>
</div>
</section>
)}
</main>
</div>
)
}

View File

@@ -0,0 +1,118 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Calendar, Link2, User, Video } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { LogoMedallion } from '@/components/ui/LogoMark'
import { createConference } from '@/api/conferences'
import { useToast } from '@/components/ui/ToastProvider'
import '@/styles/lobby.css'
/**
* Лобби — «хаб действий» (см. design/mockups/lobby.html).
*
* Предустановленных переговорных комнат больше нет: конференции создаются
* динамически. Мозаика из 4 плиток — единственный вход в остальные сценарии:
* мгновенное создание, календарь плановых конференций, вход по ссылке/номеру,
* «Мои конференции».
*
* Отклонение от макета: топбар здесь — переиспользуемый `ShellTopbar` (прямые
* ссылки Лобби/Календарь/Админка + пилюля пользователя), а не бургер-меню из
* lobby.html. `ShellTopbar` — общий компонент светлой оболочки (используется
* также в календаре/админке), поэтому бургер-меню из макета здесь
* сознательно не воспроизводится.
*/
export function LobbyPage() {
const navigate = useNavigate()
const toast = useToast()
const [creating, setCreating] = useState(false)
async function handleCreate() {
if (creating) return
setCreating(true)
try {
const conference = await createConference({})
if (!conference.join) {
toast.show('Не удалось создать конференцию. Попробуйте ещё раз', 'error')
return
}
navigate(`/room/${conference.slug}`, {
state: {
livekitUrl: conference.join.livekit_url,
token: conference.join.token,
roomName: conference.join.room_name,
conferenceId: conference.join.conference_id,
chatEnabled: conference.join.chat_enabled,
number: conference.number,
slug: conference.slug,
},
})
} catch {
toast.show('Не удалось создать конференцию. Попробуйте ещё раз', 'error')
} finally {
setCreating(false)
}
}
return (
<div className="lobby-shell">
<ShellTopbar />
<main className="hub-main">
<div className="hub-wrap">
<div className="hub-intro">
<p className="eyebrow">[ ДОБРО ПОЖАЛОВАТЬ В VIDCONF ]</p>
<h1>Что делаем сегодня?</h1>
</div>
<div className="hub">
<button
type="button"
className="action action--create"
onClick={handleCreate}
disabled={creating}
aria-busy={creating}
>
<span className="action-icon" aria-hidden="true">
<Video className="icon" aria-hidden="true" />
</span>
<h2 className="action-title">Создать</h2>
<p className="action-object">конференцию</p>
<p className="action-note">{creating ? 'Создаём…' : 'начнётся сразу — вы первый участник'}</p>
</button>
<button type="button" className="action action--calendar" onClick={() => navigate('/calendar')}>
<span className="action-icon" aria-hidden="true">
<Calendar className="icon" aria-hidden="true" />
</span>
<h2 className="action-title">Календарь</h2>
<p className="action-object">конференций</p>
<p className="action-note">запланировать · закрепить · повторять</p>
</button>
<button type="button" className="action action--join" onClick={() => navigate('/join')}>
<span className="action-icon" aria-hidden="true">
<Link2 className="icon" aria-hidden="true" />
</span>
<h2 className="action-title">Подключиться</h2>
<p className="action-object">к конференции</p>
<p className="action-note">по ссылке или номеру</p>
</button>
<button type="button" className="action action--mine" onClick={() => navigate('/my-conferences')}>
<span className="action-icon" aria-hidden="true">
<User className="icon" aria-hidden="true" />
</span>
<h2 className="action-title">Мои</h2>
<p className="action-object">конференции</p>
<p className="action-note">закреплённые и предстоящие</p>
</button>
<div className="hub-logo" aria-hidden="true">
<LogoMedallion className="logo-mark" />
<span className="logo-word">VidConf</span>
</div>
</div>
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,122 @@
import { useState, type FormEvent } from 'react'
import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
import { AlertCircle, ArrowRight, Eye, EyeOff, Lock, Mail } from 'lucide-react'
import { AuthLayout } from '@/components/auth/AuthLayout'
import { useAuth } from '@/auth/useAuth'
import { ApiError } from '@/api/client'
/** Экран входа (см. design/mockups/auth.html, блок «вход в аккаунт»). */
export function LoginPage() {
const { login, status } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
// Уже вошли — незачем показывать форму логина повторно.
if (status === 'authenticated') {
return <Navigate to="/lobby" replace />
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
setPending(true)
try {
await login(email, password)
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/lobby'
navigate(from, { replace: true })
} catch (err) {
if (err instanceof ApiError && err.status === 403) {
setError('Подтвердите почту — мы отправили письмо со ссылкой при регистрации')
} else if (err instanceof ApiError && (err.status === 401 || err.status === 400)) {
setError('Неверный email или пароль')
} else {
setError('Не удалось войти. Попробуйте ещё раз позже')
}
} finally {
setPending(false)
}
}
return (
<AuthLayout>
<section className="screen-block">
<div className="tabs" role="tablist" aria-label="Способ авторизации">
<span className="tab is-active" role="tab" aria-selected="true">
Вход
</span>
<Link to="/register" className="tab" role="tab" aria-selected="false">
Регистрация
</Link>
</div>
<form onSubmit={handleSubmit} noValidate>
<div className="field">
<label htmlFor="login-email">Рабочая почта</label>
<div className="input-wrap">
<Mail className="lucide field-icon" aria-hidden="true" />
<input
id="login-email"
type="email"
autoComplete="email"
required
placeholder="you@company.ru"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
</div>
<div className={`field${error ? ' has-error' : ''}`}>
<label htmlFor="login-pass">Пароль</label>
<div className="input-wrap">
<Lock className="lucide field-icon" aria-hidden="true" />
<input
id="login-pass"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
className="toggle-eye"
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? <EyeOff className="lucide" /> : <Eye className="lucide" />}
</button>
</div>
{error && (
<p className="error-msg">
<AlertCircle className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
{error}
</p>
)}
</div>
<div className="row-between">
<label className="checkbox-row">
<input type="checkbox" defaultChecked /> Запомнить меня
</label>
</div>
<button type="submit" className="btn btn-primary btn-block btn-lg" disabled={pending}>
{pending ? 'Входим…' : (
<>
Войти <ArrowRight className="lucide" aria-hidden="true" />
</>
)}
</button>
</form>
</section>
</AuthLayout>
)
}

View File

@@ -0,0 +1,232 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, CheckCircle2, Clock, Lock, Pencil, Play, Repeat } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
import { CopyPill } from '@/components/ui/CopyPill'
import { getMyConferences, type ConferenceOut } from '@/api/conferences'
import { formatNextOccurrence } from '@/lib/localTime'
import { addMinutesToTime, formatRecurrenceSummary } from '@/lib/recurrenceFormat'
import '@/styles/lobby.css'
import '@/styles/my-conferences.css'
type Filter = 'all' | 'pinned' | 'upcoming'
/** До начала разовой конференции можно войти заранее — порог «скоро начнётся» (UX-эвристика, не ограничение backend). */
const EARLY_JOIN_WINDOW_MS = 10 * 60 * 1000
function formatRelativeStart(iso: string): string {
const diffMs = new Date(iso).getTime() - Date.now()
const diffMin = Math.round(diffMs / 60_000)
if (diffMin <= 0) return 'вот-вот'
if (diffMin < 60) return `через ${diffMin} мин`
const diffHours = Math.round(diffMin / 60)
if (diffHours < 24) return `через ${diffHours} ч`
return `через ${Math.round(diffHours / 24)} дн.`
}
function formatSubtitle(conf: ConferenceOut): string {
if (conf.is_pinned && conf.recurrence) return formatRecurrenceSummary(conf.recurrence)
if (!conf.scheduled_at) return 'Разовая конференция'
const start = new Date(conf.scheduled_at)
const dateLabel = start.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })
const timeLabel = start.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
const endLabel = conf.duration_minutes ? addMinutesToTime(timeLabel, conf.duration_minutes) : null
return `Разовая · ${dateLabel}, ${timeLabel}${endLabel ? `${endLabel}` : ''}`
}
/**
* «Мои конференции» (design/mockups/my-conferences.html):
* закреплённые (повторяющиеся) и предстоящие разовые конференции, где текущий
* пользователь — организатор или приглашённый участник (решение от 2026-07-20,
* см. ADR-003); кнопки владельца скрываются по `is_owner`.
* Кнопка редактирования показывается только организатору (`is_owner`);
* состав участников — в ховер-карточке (ленивый запрос по наведению/фокусу).
*
* Контракт `/conferences/my` не возвращает завершённые конференции — фильтр
* «Завершённые» и карточка «Открыть саммари» из макета опущены.
*/
export function MyConferencesPage() {
const navigate = useNavigate()
const [filter, setFilter] = useState<Filter>('all')
const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null)
const [hoverTarget, setHoverTarget] = useState<{ conferenceId: string; anchorEl: HTMLElement } | null>(null)
const {
data: conferences,
isPending,
isError,
refetch,
} = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences })
const list = useMemo(() => conferences ?? [], [conferences])
const pinnedCount = list.filter((c) => c.is_pinned).length
const filtered = useMemo(() => {
if (filter === 'pinned') return list.filter((c) => c.is_pinned)
if (filter === 'upcoming') return list.filter((c) => !c.is_pinned)
return list
}, [list, filter])
function handleJoin(conf: ConferenceOut) {
navigate(`/j/${conf.slug}`)
}
return (
<div className="page-shell">
<ShellTopbar />
<main className="page-main">
<div className="page-head">
<div>
<h1>Мои конференции</h1>
<p>
{list.length} {list.length === 1 ? 'конференция' : 'конференций'} · {pinnedCount} закреплённых с повторением
</p>
</div>
<button type="button" className="btn btn-primary" onClick={() => navigate('/calendar')}>
Запланировать новую
</button>
</div>
<div className="filters">
<button type="button" className={`chip${filter === 'all' ? ' is-active' : ''}`} onClick={() => setFilter('all')}>
Все
</button>
<button type="button" className={`chip${filter === 'pinned' ? ' is-active' : ''}`} onClick={() => setFilter('pinned')}>
<Repeat style={{ width: 14, height: 14 }} aria-hidden="true" />
Закреплённые
</button>
<button type="button" className={`chip${filter === 'upcoming' ? ' is-active' : ''}`} onClick={() => setFilter('upcoming')}>
<Clock style={{ width: 14, height: 14 }} aria-hidden="true" />
Предстоящие
</button>
</div>
{isPending ? (
<p className="field-hint">Загрузка конференций</p>
) : isError ? (
<div className="error-banner">
<AlertTriangle style={{ width: 18, height: 18 }} aria-hidden="true" />
<div style={{ flex: 1 }}>Не удалось загрузить конференции. Проверьте соединение и попробуйте ещё раз.</div>
<button type="button" className="btn btn-secondary" style={{ width: 'auto' }} onClick={() => refetch()}>
Повторить
</button>
</div>
) : filtered.length === 0 ? (
<p className="field-hint">Здесь пока пусто запланируйте конференцию в календаре.</p>
) : (
<section className="conf-grid">
{filtered.map((conf) => {
const joinable =
conf.is_pinned || conf.status === 'active' || (conf.scheduled_at
? new Date(conf.scheduled_at).getTime() - new Date().getTime() <= EARLY_JOIN_WINDOW_MS
: false)
const link = `${window.location.origin}/j/${conf.slug}`
return (
<article
className={`conf-card${conf.status === 'ended' ? ' is-ended' : ''}`}
key={conf.id}
onMouseEnter={(e) => setHoverTarget({ conferenceId: conf.id, anchorEl: e.currentTarget })}
onMouseLeave={() => setHoverTarget((prev) => (prev?.conferenceId === conf.id ? null : prev))}
onFocus={(e) => setHoverTarget({ conferenceId: conf.id, anchorEl: e.currentTarget })}
onBlur={() => setHoverTarget((prev) => (prev?.conferenceId === conf.id ? null : prev))}
>
<div className="conf-card-head">
<div className="conf-title-wrap">
<h3>
{conf.is_closed && <Lock style={{ width: 15, height: 15, flexShrink: 0 }} aria-hidden="true" />}
{conf.title ?? 'Конференция без названия'}
</h3>
<p>{formatSubtitle(conf)}</p>
</div>
{conf.is_pinned ? (
<span className="conf-badge pinned">
<Repeat style={{ width: 12, height: 12 }} aria-hidden="true" />
Закреплена
</span>
) : conf.status === 'active' ? (
<span className="conf-badge live">
<Play style={{ width: 12, height: 12 }} aria-hidden="true" />
Идёт сейчас
</span>
) : conf.status === 'ended' ? (
<span className="conf-badge ended">
<CheckCircle2 style={{ width: 12, height: 12 }} aria-hidden="true" />
Завершена
</span>
) : (
<span className="conf-badge scheduled">
<Clock style={{ width: 12, height: 12 }} aria-hidden="true" />
Предстоящая
</span>
)}
</div>
{conf.is_pinned && conf.next_occurrence && (
<div className="conf-meta-row">
<span className="meta-item">
<Clock style={{ width: 15, height: 15 }} aria-hidden="true" />
Ближайшее: {formatNextOccurrence(conf.next_occurrence)}
</span>
</div>
)}
{conf.status !== 'ended' && (
<div className="pills-row">
<CopyPill label="Ссылка" value={link.replace(/^https?:\/\//, '')} copyText={link} />
<CopyPill label="Номер" value={conf.number} copyText={conf.number} />
</div>
)}
<div className="conf-card-footer">
{conf.status === 'ended' ? (
<button type="button" className="btn btn-secondary" style={{ flex: 1 }} disabled>
Конференция завершена
</button>
) : joinable ? (
<button type="button" className="btn btn-primary" onClick={() => handleJoin(conf)}>
{conf.is_closed ? (
<Lock style={{ width: 16, height: 16 }} aria-hidden="true" />
) : (
<Play style={{ width: 16, height: 16 }} aria-hidden="true" />
)}
{conf.is_closed ? 'Войти по паролю' : 'Войти'}
</button>
) : (
<button type="button" className="btn btn-secondary" style={{ flex: 1 }} disabled>
<Clock style={{ width: 16, height: 16 }} aria-hidden="true" />
Начнётся {formatRelativeStart(conf.scheduled_at!)}
</button>
)}
{conf.status !== 'ended' && conf.is_owner && (
<button type="button" className="icon-btn" aria-label="Редактировать" onClick={() => setEditingConference(conf)}>
<Pencil style={{ width: 17, height: 17 }} aria-hidden="true" />
</button>
)}
</div>
</article>
)
})}
</section>
)}
</main>
{editingConference && (
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={() => setEditingConference(null)}>
<div className="modal-form-wrap" onClick={(e) => e.stopPropagation()}>
<ConferenceFormCard
conference={editingConference}
onSaved={() => setEditingConference(null)}
onDeleted={() => setEditingConference(null)}
onCancelEdit={() => setEditingConference(null)}
/>
</div>
</div>
)}
<ConferenceHoverCard conferenceId={hoverTarget?.conferenceId ?? null} anchorEl={hoverTarget?.anchorEl ?? null} />
</div>
)
}

View File

@@ -0,0 +1,276 @@
import { useRef, useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Trash2, Upload } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { Avatar } from '@/components/ui/Avatar'
import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider'
import { useAuth } from '@/auth/useAuth'
import { changePassword, deleteMyAvatar, listTeams, updateMe, uploadMyAvatar } from '@/api/users'
import { ApiError, errorDetail } from '@/api/client'
import '@/styles/lobby.css'
import '@/styles/profile.css'
/** Разрешённые типы файла аватара — те же ограничения, что и на backend. */
const ALLOWED_AVATAR_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const MAX_AVATAR_BYTES = 2 * 1024 * 1024
/**
* Страница профиля. Своя (текущий пользователь):
* ФИО и команда редактируются, email — только чтение, аватар — загрузка с
* клиентской предпроверкой типа/размера (дублирует лимиты backend — 413/415
* обрабатываются и на случай, если предпроверка почему-то разошлась с
* реальным лимитом сервера).
*
* Справочник команд — аутентифицированный `GET /api/v1/teams` (не зависит
* от тумблера «разрешить выбор команды при регистрации», в отличие от
* `GET /auth/registration-options`, который для этого экрана не подходит).
*/
export function ProfilePage() {
const { user, refreshUser } = useAuth()
const toast = useToast()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [name, setName] = useState(user?.name_user ?? '')
const [teamId, setTeamId] = useState(user?.team_id ?? '')
const [avatarError, setAvatarError] = useState<string | null>(null)
const { data: teams } = useQuery({ queryKey: ['teams'], queryFn: listTeams })
const teamOptions = [{ value: '', label: 'Без команды' }, ...((teams ?? []).map((t) => ({ value: t.id, label: t.name })))]
async function afterProfileChange() {
await refreshUser()
queryClient.invalidateQueries({ queryKey: ['conferences'] })
}
const saveMutation = useMutation({
mutationFn: () => updateMe({ name_user: name.trim(), team_id: teamId === '' ? null : teamId }),
onSuccess: async () => {
await afterProfileChange()
toast.show('Профиль сохранён', 'success')
},
onError: () => toast.show('Не удалось сохранить профиль', 'error'),
})
const avatarMutation = useMutation({
mutationFn: (file: File) => uploadMyAvatar(file),
onSuccess: async () => {
await afterProfileChange()
toast.show('Аватар обновлён', 'success')
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 413) {
toast.show('Файл слишком большой — максимум 2 МБ', 'error')
} else if (err instanceof ApiError && err.status === 415) {
toast.show('Недопустимый формат — только JPEG, PNG или WEBP', 'error')
} else {
toast.show('Не удалось загрузить аватар', 'error')
}
},
})
const deleteAvatarMutation = useMutation({
mutationFn: () => deleteMyAvatar(),
onSuccess: async () => {
await afterProfileChange()
toast.show('Аватар удалён', 'success')
},
onError: () => toast.show('Не удалось удалить аватар', 'error'),
})
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordError, setPasswordError] = useState<string | null>(null)
const passwordMutation = useMutation({
mutationFn: () => changePassword({ current_password: currentPassword, new_password: newPassword }),
onSuccess: () => {
toast.show('Пароль изменён', 'success')
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'invalid_current_password') {
setPasswordError('Неверный текущий пароль')
} else {
toast.show('Не удалось сменить пароль', 'error')
}
},
})
function handlePasswordSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setPasswordError(null)
if (newPassword.length < 8) {
setPasswordError('Новый пароль должен быть не короче 8 символов')
return
}
if (newPassword !== confirmPassword) {
setPasswordError('Пароли не совпадают')
return
}
passwordMutation.mutate()
}
function handleFileChange(file: File | null) {
setAvatarError(null)
if (!file) return
if (!ALLOWED_AVATAR_TYPES.includes(file.type)) {
setAvatarError('Недопустимый формат — только JPEG, PNG или WEBP')
return
}
if (file.size > MAX_AVATAR_BYTES) {
setAvatarError('Файл слишком большой — максимум 2 МБ')
return
}
avatarMutation.mutate(file)
}
if (!user) return null
return (
<div className="page-shell">
<ShellTopbar />
<main className="page-main">
<div className="page-head">
<div>
<h1>Профиль</h1>
<p>Личные данные, команда и фото видны другим участникам в составе конференций.</p>
</div>
</div>
<section className="profile-card">
<div className="profile-avatar-block">
<Avatar name={user.name_user} avatarUrl={user.avatar_url} size={88} />
<div className="profile-avatar-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => fileInputRef.current?.click()}
disabled={avatarMutation.isPending}
>
<Upload style={{ width: 16, height: 16 }} aria-hidden="true" />
{avatarMutation.isPending ? 'Загружаем…' : 'Загрузить фото'}
</button>
{user.avatar_url && (
<button
type="button"
className="icon-btn"
aria-label="Удалить аватар"
onClick={() => deleteAvatarMutation.mutate()}
disabled={deleteAvatarMutation.isPending}
>
<Trash2 style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
style={{ display: 'none' }}
onChange={(e) => {
handleFileChange(e.target.files?.[0] ?? null)
e.target.value = ''
}}
/>
</div>
{avatarError && <p className="field-hint" style={{ color: 'var(--color-danger)' }}>{avatarError}</p>}
</div>
<form
onSubmit={(e) => {
e.preventDefault()
saveMutation.mutate()
}}
>
<div className="field">
<label htmlFor="profile-name">ФИО</label>
<input id="profile-name" type="text" value={name} onChange={(e) => setName(e.target.value)} maxLength={255} />
</div>
<div className="field">
<label htmlFor="profile-email">Email</label>
<input id="profile-email" type="email" value={user.email} disabled readOnly />
</div>
<div className="field">
<label id="profile-team-label" htmlFor="profile-team">
Команда
</label>
<Select
id="profile-team"
aria-labelledby="profile-team-label"
value={teamId ?? ''}
onChange={setTeamId}
options={teamOptions}
/>
</div>
<button type="submit" className="btn btn-primary" disabled={saveMutation.isPending || !name.trim()}>
{saveMutation.isPending ? 'Сохраняем…' : 'Сохранить изменения'}
</button>
</form>
</section>
<section className="profile-card">
<h2>Смена пароля</h2>
<form onSubmit={handlePasswordSubmit}>
<div className="field">
<label htmlFor="profile-current-password">Текущий пароль</label>
<input
id="profile-current-password"
type="password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="profile-new-password">Новый пароль</label>
<input
id="profile-new-password"
type="password"
autoComplete="new-password"
required
minLength={8}
placeholder="Минимум 8 символов"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className={`field${passwordError ? ' has-error' : ''}`}>
<label htmlFor="profile-confirm-password">Подтверждение пароля</label>
<input
id="profile-confirm-password"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
{passwordError && (
<p className="field-hint" style={{ color: 'var(--color-danger)' }}>
{passwordError}
</p>
)}
</div>
<button
type="submit"
className="btn btn-primary"
disabled={passwordMutation.isPending || !currentPassword || !newPassword || !confirmPassword}
>
{passwordMutation.isPending ? 'Сохраняем…' : 'Сменить пароль'}
</button>
</form>
</section>
</main>
</div>
)
}

View File

@@ -0,0 +1,193 @@
import { useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Clock, Mail, Lock, User, Users } from 'lucide-react'
import { AuthLayout } from '@/components/auth/AuthLayout'
import { getRegistrationOptions, register } from '@/api/auth'
import { ApiError, errorDetail } from '@/api/client'
/** Текст предупреждения о несовпадении домена почты с эталонным. */
function domainMismatchMessage(domain: string): string {
return `Укажите рабочую почту — регистрация доступна только для домена @${domain}`
}
/**
* Экран регистрации (см. design/mockups/auth.html, блок «регистрация»).
* Поле «Команда» показывается только если выбор команды включён в
* настройках инстанса (`GET /auth/registration-options`, публичный эндпоинт).
* Поле «Рабочая почта» дополнительно сверяется с эталонным доменом
* (`registration-options.email_domain`), если в админке включена
* верификация по домену — проверка идёт по blur и при сабмите, backend
* при включённой верификации и чужом домене отвечает 400 `invalid_email_domain`.
* После успешной регистрации показывает состояние «подтвердите почту»
* (письмо со ссылкой backend в dev-режиме печатает в консоль).
*/
export function RegisterPage() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [teamId, setTeamId] = useState('')
const [error, setError] = useState<string | null>(null)
const [emailDomainError, setEmailDomainError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null)
const { data: registrationOptions } = useQuery({
queryKey: ['auth', 'registration-options'],
queryFn: getRegistrationOptions,
})
const teamChoiceEnabled = registrationOptions?.team_choice_enabled ?? false
const teams = registrationOptions?.teams ?? []
const expectedEmailDomain = registrationOptions?.email_domain ?? null
/** Домен после «@» не совпадает (без учёта регистра) с эталонным — `null`, если сверять не с чем. */
function checkEmailDomain(value: string): string | null {
if (!expectedEmailDomain) return null
const atIndex = value.lastIndexOf('@')
if (atIndex === -1) return null
const domain = value.slice(atIndex + 1).trim().toLowerCase()
if (domain !== expectedEmailDomain.toLowerCase()) {
return domainMismatchMessage(expectedEmailDomain)
}
return null
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
const domainError = checkEmailDomain(email)
setEmailDomainError(domainError)
if (domainError) return
setPending(true)
try {
await register({ email, name_user: name, password, team_id: teamChoiceEnabled ? teamId || null : null })
setRegisteredEmail(email)
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setError('Пользователь с таким email уже зарегистрирован')
} else if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'invalid_email_domain') {
setEmailDomainError(expectedEmailDomain ? domainMismatchMessage(expectedEmailDomain) : 'Регистрация с этим доменом почты недоступна')
} else {
setError('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова')
}
} finally {
setPending(false)
}
}
if (registeredEmail) {
return (
<AuthLayout>
<section className="screen-block">
<div className="confirm-card pending">
<div className="confirm-icon">
<Clock className="lucide" aria-hidden="true" />
</div>
<h3>Подтвердите почту</h3>
<p>
Мы отправили письмо со ссылкой на{' '}
<span className="mail-chip">
<Mail className="lucide" style={{ width: 14, height: 14 }} aria-hidden="true" />
{registeredEmail}
</span>
</p>
<Link to="/login" className="btn btn-secondary btn-block btn-lg">
Перейти ко входу
</Link>
</div>
</section>
</AuthLayout>
)
}
return (
<AuthLayout>
<section className="screen-block">
<div className="tabs" role="tablist" aria-label="Способ авторизации">
<Link to="/login" className="tab" role="tab" aria-selected="false">
Вход
</Link>
<span className="tab is-active" role="tab" aria-selected="true">
Регистрация
</span>
</div>
<h2 className="screen-title">Создать аккаунт</h2>
<form onSubmit={handleSubmit} noValidate>
<div className="field">
<label htmlFor="reg-name">Имя и фамилия</label>
<div className="input-wrap">
<User className="lucide field-icon" aria-hidden="true" />
<input
id="reg-name"
type="text"
required
placeholder="Мария Кузнецова"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
<div className={`field${emailDomainError ? ' has-error' : ''}`}>
<label htmlFor="reg-email">Рабочая почта</label>
<div className="input-wrap">
<Mail className="lucide field-icon" aria-hidden="true" />
<input
id="reg-email"
type="email"
autoComplete="email"
required
placeholder="you@company.ru"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={(e) => setEmailDomainError(checkEmailDomain(e.target.value))}
/>
</div>
{emailDomainError && <p className="error-msg">{emailDomainError}</p>}
</div>
{teamChoiceEnabled && (
<div className="field">
<label htmlFor="reg-team">Команда</label>
<div className="input-wrap">
<Users className="lucide field-icon" aria-hidden="true" />
<select id="reg-team" value={teamId} onChange={(e) => setTeamId(e.target.value)}>
<option value="">Иное</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
</div>
</div>
)}
<div className={`field${error ? ' has-error' : ''}`}>
<label htmlFor="reg-pass">Пароль</label>
<div className="input-wrap">
<Lock className="lucide field-icon" aria-hidden="true" />
<input
id="reg-pass"
type="password"
autoComplete="new-password"
required
minLength={8}
placeholder="Минимум 8 символов"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error ? (
<p className="error-msg">{error}</p>
) : (
<p className="hint-msg">Минимум 8 символов, буквы и цифры</p>
)}
</div>
<button type="submit" className="btn btn-primary btn-block btn-lg" disabled={pending}>
{pending ? 'Регистрируем…' : 'Зарегистрироваться'}
</button>
</form>
</section>
</AuthLayout>
)
}

View File

@@ -0,0 +1,250 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { PictureInPicture2 } from 'lucide-react'
import { LiveKitRoom, usePersistentUserChoices } from '@livekit/components-react'
import type { RoomOptions } from 'livekit-client'
import '@livekit/components-styles'
import '@/styles/room.css'
import { joinConference, resolveConference } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { useChat } from '@/hooks/useChat'
import { useFullscreen } from '@/hooks/useFullscreen'
import { useRoomPiP } from '@/hooks/useRoomPiP'
import { RoomTopbar } from '@/components/room/RoomTopbar'
import { RoomStage } from '@/components/room/RoomStage'
import { RoomToolbar } from '@/components/room/RoomToolbar'
import { ChatPanel } from '@/components/room/ChatPanel'
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
interface RoomJoinState {
livekitUrl: string
token: string
roomName: string
conferenceId?: string
/** Номер конференции — для инвайт-чипа в топбаре (см. RoomTopbar). Есть только сразу после создания. */
number?: string
/** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */
chatEnabled?: boolean
}
/**
* Экран комнаты конференции (тёмная тема, `data-theme="room"`).
*
* Публичный маршрут: гость входит без JWT-аутентификации приложения — его
* LiveKit-токен получен через `POST /conferences/{id}/guest-join` (см.
* JoinPage) и передан сюда через navigation state, ровно как и токен
* авторизованного пользователя. RoomPage сам никогда не проверяет сессию —
* подключение к LiveKit целиком определяется переданными `token`/`livekitUrl`.
*
* `:slug` в адресе — тот же идентификатор (slug или номер), что пользователь
* вводил на JoinPage; используется только для повторного резолва при прямой
* перезагрузке страницы (join-state из state теряется при F5). Если состояния
* нет — не пытаемся угадать креды сами: гостю это в принципе невозможно
* (нет сохранённых имени/пароля), поэтому просто уводим на `/join?q=:slug`,
* где резолв и вход повторяются штатным флоу.
*/
export function RoomPage() {
const { slug } = useParams<{ slug: string }>()
const location = useLocation()
const navigate = useNavigate()
const { status } = useAuth()
const initialState = (location.state as RoomJoinState | null) ?? null
const [joinState, setJoinState] = useState<RoomJoinState | null>(initialState)
const [error, setError] = useState<string | null>(null)
// useCallback: стабильная ссылка на обработчик — иначе на каждом рендере
// RoomPage пересобирался бы объект-пропс `onDisconnected`, а с ним и
// внутренний useEffect @livekit/components-react (он в списке зависимостей
// хука useLiveKitRoom), лишний раз пересоздавая подписки на события Room.
const handleDisconnected = useCallback(() => {
navigate(status === 'authenticated' ? '/lobby' : '/join')
}, [navigate, status])
useEffect(() => {
if (joinState || !slug) return
// Сессия ещё восстанавливается (silent-refresh) — подождём, чтобы не
// отправить авторизованного пользователя в гостевой флоу по ошибке.
if (status === 'loading') return
if (status !== 'authenticated') {
navigate(`/join?q=${encodeURIComponent(slug)}`, { replace: true })
return
}
// Авторизованный пользователь перезагрузил страницу комнаты — пробуем
// молча переподключиться тем же slug/номером, что был в адресе.
let cancelled = false
async function reconnect() {
try {
const info = await resolveConference(slug!)
if (info.requires_password) {
if (!cancelled) navigate(`/join?q=${encodeURIComponent(slug!)}`, { replace: true })
return
}
const result = await joinConference(info.id)
if (!cancelled) {
setJoinState({
livekitUrl: result.livekit_url,
token: result.token,
roomName: result.room_name,
conferenceId: result.conference_id,
chatEnabled: result.chat_enabled,
})
}
} catch (err) {
if (cancelled) return
if (err instanceof ApiError && (err.status === 404 || err.status === 410)) {
setError('Конференция недоступна или уже завершилась')
} else if (err instanceof ApiError && err.status === 403 && errorDetail(err) === 'password_required') {
navigate(`/join?q=${encodeURIComponent(slug!)}`, { replace: true })
} else {
setError('Не удалось подключиться к конференции. Попробуйте войти ещё раз')
}
}
}
void reconnect()
return () => {
cancelled = true
}
}, [joinState, slug, status, navigate])
// WS-соединение чата живёт независимо от того, открыта ли панель, — иначе
// при сворачивании панели терялась бы история и обрывался счётчик
// непрочитанных. Хук вызывается безусловно (до ранних return ниже) —
// conferenceId/token до входа в комнату ещё не известны, enabled это учитывает.
const chat = useChat({
conferenceId: joinState?.conferenceId,
token: joinState?.token,
enabled: Boolean(joinState?.chatEnabled),
})
const [chatOpen, setChatOpen] = useState(false)
// Сколько сообщений уже «видели» (на момент последнего открытия панели) —
// считается прямо в теле рендера, под охраной сравнения («adjusting state
// during rendering», тот же санкционированный паттерн, что и focus-логика
// в RoomStage.tsx); useEffect тут не подходит — react-hooks/set-state-in-effect,
// а ref нельзя ни читать, ни писать во время рендера — react-hooks/refs.
const [chatSeenCount, setChatSeenCount] = useState(0)
if (chatOpen && chatSeenCount !== chat.messages.length) {
setChatSeenCount(chat.messages.length)
}
// Счётчик непрочитанных — сообщения, накопленные с момента последнего
// открытия панели (включая историю при первом подключении, если панель
// открывается впервые уже после её загрузки); при закрытой панели счётчик
// сохраняется (см. design/mockups/room.html, .collapsed-demo).
const chatUnreadCount = Math.max(0, chat.messages.length - chatSeenCount)
// Чат выключен на сервере (close-код 4404) — панель нужно скрыть, даже если
// изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте).
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
// Корневой контейнер комнаты — цель для fullscreen и источник video-элемента
// для video-PiP-фолбэка.
const roomRootRef = useRef<HTMLDivElement>(null)
const fullscreen = useFullscreen(roomRootRef)
const pip = useRoomPiP(roomRootRef)
const [settingsOpen, setSettingsOpen] = useState(false)
// Сохранённый выбор устройств — читаем через собственный вызов
// usePersistentUserChoices (независимый от того, что использует
// DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО:
// ссылка на `roomOptions` должна быть стабильной между рендерами — иначе
// LiveKitRoom получает «новый» объект `options` и переподключается
// Это безопасно мемоизировать по `userChoices`,
// потому что `userChoices` ЭТОГО вызова хука меняется, только если МЫ САМИ
// вызовем saveAudioInputDeviceId/saveVideoInputDeviceId НА НЁМ — а мы этого
// не делаем (сохранение — только в DeviceSettingsDialog).
const { userChoices } = usePersistentUserChoices()
const roomOptions = useMemo<RoomOptions>(
() => ({
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
}),
[userChoices],
)
if (error) {
return (
<div data-theme="room" className="room-error">
<p>{error}</p>
<button type="button" className="btn btn-primary" onClick={() => navigate('/lobby')}>
Вернуться в лобби
</button>
</div>
)
}
if (!joinState) {
return (
<div data-theme="room" className="room-loader" role="status" aria-live="polite">
Подключаемся к конференции
</div>
)
}
return (
<div data-theme="room" ref={roomRootRef}>
<LiveKitRoom
serverUrl={joinState.livekitUrl}
token={joinState.token}
connect
audio
video
options={roomOptions}
onDisconnected={handleDisconnected}
>
<div data-lk-theme="default" className="room-shell">
<RoomTopbar roomName={joinState.roomName} slug={slug} number={joinState.number} />
<div className="room-main">
{pip.mode === 'document' ? (
// Сцена сейчас рисуется в PiP-окне (через createPortal ниже) —
// основное окно вместо неё показывает заглушку с возвратом.
<div className="room-pip-placeholder">
<PictureInPicture2 aria-hidden="true" />
<p>Конференция открыта в отдельном мини-окне</p>
<button type="button" className="btn btn-secondary" onClick={pip.toggle}>
Вернуть в это окно
</button>
</div>
) : (
<RoomStage />
)}
{chatVisible && chatOpen && (
<ChatPanel
messages={chat.messages}
status={chat.status}
statusMessage={chat.statusMessage}
onSend={chat.sendMessage}
onClose={() => setChatOpen(false)}
/>
)}
</div>
<RoomToolbar
chatVisible={chatVisible}
chatOpen={chatOpen}
chatUnreadCount={chatUnreadCount}
onToggleChat={() => setChatOpen((open) => !open)}
onToggleSettings={() => setSettingsOpen(true)}
fullscreenSupported={fullscreen.supported}
fullscreenActive={fullscreen.active}
onToggleFullscreen={fullscreen.toggle}
pipSupported={pip.supported}
pipActive={pip.active}
onTogglePiP={pip.toggle}
/>
</div>
{settingsOpen && <DeviceSettingsDialog onClose={() => setSettingsOpen(false)} />}
{/* Портал внутрь LiveKitRoom (не как сосед снаружи!) — RoomStage,
рендерящийся в PiP-окне, продолжает читать RoomContext/треки.
`variant="pip"` — мини-плеер
показывает только активное окно (одну плитку), без карусели/грида
основного окна. */}
{pip.pipWindow && createPortal(<RoomStage variant="pip" />, pip.pipWindow.document.body)}
</LiveKitRoom>
</div>
)
}

View File

@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { AlertTriangle, ArrowRight, CheckCircle2, Loader2 } from 'lucide-react'
import { AuthLayout } from '@/components/auth/AuthLayout'
import { verifyEmail } from '@/api/auth'
type VerifyState = 'verifying' | 'success' | 'error'
/**
* Кэш промисов подтверждения по токену (модульный, вне компонента) — тот же
* приём дедупликации, что и у refreshPromise в api/client.ts. Защищает от
* повторного POST /auth/verify-email при двойном монтировании эффекта в
* React StrictMode (dev): без кэша второй запрос уходит с уже погашенным
* токеном и backend отвечает 400 «токен уже использован», хотя первый запрос
* уже успешно подтвердил почту.
*/
const verifyRequests = new Map<string, Promise<void>>()
function verifyEmailOnce(token: string): Promise<void> {
let request = verifyRequests.get(token)
if (!request) {
request = verifyEmail(token)
verifyRequests.set(token, request)
}
return request
}
/**
* Экран подтверждения email по ссылке из письма: /verify-email?token=...
* (см. design/mockups/auth.html, блок «подтверждение email»).
*/
export function VerifyEmailPage() {
const [searchParams] = useSearchParams()
const token = searchParams.get('token')
const [state, setState] = useState<VerifyState>(token ? 'verifying' : 'error')
useEffect(() => {
if (!token) return
let cancelled = false
verifyEmailOnce(token)
.then(() => {
if (!cancelled) setState('success')
})
.catch(() => {
if (!cancelled) setState('error')
})
return () => {
cancelled = true
}
}, [token])
return (
<AuthLayout>
<section className="screen-block">
{state === 'verifying' && (
<div className="confirm-card pending">
<div className="confirm-icon">
<Loader2 className="lucide spin" aria-hidden="true" />
</div>
<h3>Проверяем ссылку</h3>
<p>Подтверждаем вашу почту, это займёт пару секунд.</p>
</div>
)}
{state === 'success' && (
<div className="confirm-card">
<div className="confirm-icon">
<CheckCircle2 className="lucide" aria-hidden="true" />
</div>
<h3>Почта подтверждена</h3>
<p>Аккаунт активирован. Теперь доступно бронирование переговорных и вход в конференции.</p>
<Link to="/login" className="btn btn-primary btn-block btn-lg">
Перейти ко входу <ArrowRight className="lucide" aria-hidden="true" />
</Link>
</div>
)}
{state === 'error' && (
<div className="confirm-card error">
<div className="confirm-icon">
<AlertTriangle className="lucide" aria-hidden="true" />
</div>
<h3>Ссылка недействительна</h3>
<p>Срок действия ссылки истёк или она уже была использована. Запросите письмо ещё раз со страницы регистрации.</p>
<Link to="/register" className="btn btn-secondary btn-block btn-lg">
Вернуться к регистрации
</Link>
</div>
)}
</section>
</AuthLayout>
)
}