Первоначальная версия VidConf
This commit is contained in:
73
frontend/src/pages/AdminPage.tsx
Normal file
73
frontend/src/pages/AdminPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
frontend/src/pages/CalendarPage.tsx
Normal file
117
frontend/src/pages/CalendarPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
414
frontend/src/pages/JoinPage.tsx
Normal file
414
frontend/src/pages/JoinPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
frontend/src/pages/LobbyPage.tsx
Normal file
118
frontend/src/pages/LobbyPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
122
frontend/src/pages/LoginPage.tsx
Normal file
122
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
232
frontend/src/pages/MyConferencesPage.tsx
Normal file
232
frontend/src/pages/MyConferencesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
276
frontend/src/pages/ProfilePage.tsx
Normal file
276
frontend/src/pages/ProfilePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
193
frontend/src/pages/RegisterPage.tsx
Normal file
193
frontend/src/pages/RegisterPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
250
frontend/src/pages/RoomPage.tsx
Normal file
250
frontend/src/pages/RoomPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
frontend/src/pages/VerifyEmailPage.tsx
Normal file
93
frontend/src/pages/VerifyEmailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user