Первоначальная версия 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,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>
)
}