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

79
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,79 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { LoginPage } from '@/pages/LoginPage'
import { RegisterPage } from '@/pages/RegisterPage'
import { VerifyEmailPage } from '@/pages/VerifyEmailPage'
import { LobbyPage } from '@/pages/LobbyPage'
import { JoinPage } from '@/pages/JoinPage'
import { RoomPage } from '@/pages/RoomPage'
import { CalendarPage } from '@/pages/CalendarPage'
import { MyConferencesPage } from '@/pages/MyConferencesPage'
import { ProfilePage } from '@/pages/ProfilePage'
import { AdminPage } from '@/pages/AdminPage'
import { RequireAuth } from '@/auth/RequireAuth'
import { RequireAdmin } from '@/auth/RequireAdmin'
function App() {
return (
<Routes>
<Route path="/" element={<Navigate to="/lobby" replace />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route
path="/lobby"
element={
<RequireAuth>
<LobbyPage />
</RequireAuth>
}
/>
{/* Подключение по ссылке/номеру — публичное: доступно и гостю без аккаунта. */}
<Route path="/join" element={<JoinPage />} />
<Route path="/j/:slug" element={<JoinPage />} />
{/*
* Комната конференции — тоже публичная: гость подключается по токену,
* переданному через navigation state из JoinPage (без JWT-сессии
* приложения). Авторизация внутри самой страницы не требуется — см.
* комментарий в RoomPage.tsx.
*/}
<Route path="/room/:slug" element={<RoomPage />} />
<Route
path="/calendar"
element={
<RequireAuth>
<CalendarPage />
</RequireAuth>
}
/>
<Route
path="/my-conferences"
element={
<RequireAuth>
<MyConferencesPage />
</RequireAuth>
}
/>
<Route
path="/profile"
element={
<RequireAuth>
<ProfilePage />
</RequireAuth>
}
/>
<Route
path="/admin"
element={
<RequireAuth>
<RequireAdmin>
<AdminPage />
</RequireAdmin>
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/lobby" replace />} />
</Routes>
)
}
export default App

260
frontend/src/api/admin.ts Normal file
View File

@@ -0,0 +1,260 @@
/**
* API-клиент админ-эндпоинтов (`/api/v1/admin/*`).
* Списковые DTO (`AdminConferenceOut`/`AdminUserOut`) отдаются в стандартном
* конверте пагинации `items`/`total`.
*/
import { apiRequest } from '@/api/client'
import type { ConferenceRecurrence, ConferenceStatus, SummaryRecipientsMode } from '@/api/conferences'
/** Уровень качества AI-обработки (транскрибация + суммаризация). */
export type AiLevel = 'min' | 'medium' | 'max'
/** Доступность одного уровня AI с причиной отказа (если недоступен). */
export interface AiLevelStatus {
level: AiLevel
available: boolean
reason: string | null
}
/** Эффективные настройки инстанса. */
export interface SettingsOut {
chat_enabled: boolean
/** Единый переключатель модуля AI (транскрибация + суммаризация). */
transcription_enabled: boolean
/** Есть ли хотя бы один Celery-воркер, обслуживающий очередь транскрибации. */
transcription_queue_served: boolean
ai_level: AiLevel
ai_levels: AiLevelStatus[]
summary_recipients: SummaryRecipientsMode
/** IANA-таймзона отображения времени в письмах/.ics (напр. `Europe/Moscow`). */
display_timezone: string
/** Разрешён ли выбор команды на экране регистрации. */
registration_team_choice: boolean
/** Включена ли верификация регистрации по домену корпоративной почты. */
registration_email_domain_enabled: boolean
/** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */
registration_email_domain: string | null
}
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
export interface SettingsUpdateIn {
chat_enabled?: boolean
transcription_enabled?: boolean
/** Недоступный уровень (см. `ai_levels`) — backend отвечает 400. */
ai_level?: AiLevel
summary_recipients?: SummaryRecipientsMode
display_timezone?: string
registration_team_choice?: boolean
/** Включение без домена или невалидный домен — backend отвечает 400. */
registration_email_domain_enabled?: boolean
registration_email_domain?: string | null
}
/** Конференция в ответе админ-списка — `ConferenceOut` + сведения о владельце. */
export interface AdminConferenceOut {
id: string
number: string
slug: string
title: string | null
status: ConferenceStatus
is_pinned: boolean
is_closed: boolean
scheduled_at: string | null
duration_minutes: number | null
recurrence: ConferenceRecurrence | null
next_occurrence: string | null
created_at: string
summary_recipients: SummaryRecipientsMode | null
owner_name: string | null
owner_email: string | null
}
/** Параметры выборки списка конференций. */
export interface AdminConferenceQuery {
status?: ConferenceStatus
q?: string
limit?: number
offset?: number
}
/** Тело правки конференции админом — те же поля, что и в пользовательском API. */
export interface AdminConferenceUpdatePayload {
title?: string
scheduled_at?: string
duration_minutes?: number
is_pinned?: boolean
recurrence?: ConferenceRecurrence
is_closed?: boolean
password?: string
summary_recipients?: SummaryRecipientsMode | null
}
/** Пользователь в ответе админ-списка. */
export interface AdminUserOut {
id: string
email: string
name_user: string
role: 'admin' | 'user'
is_blocked: boolean
email_verified: boolean
created_at: string
/** Команда пользователя — `null`, если не назначена. */
team_id: string | null
/** URL аватара с cache-busting (`?v=`) — `null`, если фото не загружено. */
avatar_url: string | null
team_name: string | null
}
/**
* Карточка профиля пользователя для админки — тот же
* набор полей, что и в списке (backend отдаёт аватар и команду и в списке,
* и в карточке).
*/
export type AdminUserDetailOut = AdminUserOut
/** Параметры выборки списка пользователей. */
export interface AdminUserQuery {
q?: string
limit?: number
offset?: number
}
/** Тело правки пользователя — роль, блокировка, ФИО и/или команда, все поля опциональны. */
export interface AdminUserUpdatePayload {
role?: 'admin' | 'user'
is_blocked?: boolean
name_user?: string
/** `null` — снять пользователя с команды. */
team_id?: string | null
}
/** Тело создания пользователя администратором. */
export interface AdminUserCreateIn {
name_user: string
email: string
/** Та же политика минимальной длины, что и при самостоятельной регистрации. */
password: string
team_id?: string | null
}
/** Команда в ответе админ-списка. */
export interface TeamOut {
id: string
name: string
created_at: string
}
/** Параметры выборки списка команд. */
export interface AdminTeamQuery {
limit?: number
offset?: number
}
/** Стандартный конверт пагинации списковых админ-эндпоинтов. */
export interface PagedResult<T> {
items: T[]
total: number
}
function toQueryString(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== '') search.set(key, String(value))
}
const qs = search.toString()
return qs ? `?${qs}` : ''
}
/** Список конференций для админки — с фильтром по статусу, поиском и пагинацией. */
export async function listAdminConferences(query: AdminConferenceQuery = {}): Promise<PagedResult<AdminConferenceOut>> {
const qs = toQueryString({ status: query.status, q: query.q, limit: query.limit, offset: query.offset })
return apiRequest<PagedResult<AdminConferenceOut>>(`/admin/conferences${qs}`)
}
/** Частично обновить конференцию от имени администратора (реюз `ConferenceService.update`). */
export async function updateAdminConference(
id: string,
payload: AdminConferenceUpdatePayload,
): Promise<AdminConferenceOut> {
return apiRequest<AdminConferenceOut>(`/admin/conferences/${id}`, { method: 'PATCH', body: payload })
}
/** Удалить конференцию навсегда от имени администратора. 409 — конференция сейчас идёт. */
export async function deleteAdminConference(id: string): Promise<void> {
await apiRequest(`/admin/conferences/${id}`, { method: 'DELETE' })
}
/**
* Ручная рассылка .ics-приглашений по конференции. Без `emails` — получатели
* по умолчанию (владелец + участники прошлых сеансов для закреплённых).
* 202 — задача поставлена в очередь, письма отправляются асинхронно.
*/
export async function sendConferenceInvitations(id: string, emails?: string[]): Promise<void> {
await apiRequest(`/admin/conferences/${id}/invitations`, {
method: 'POST',
body: emails && emails.length > 0 ? { emails } : {},
})
}
/** Список пользователей для админки — с поиском и пагинацией. */
export async function listAdminUsers(query: AdminUserQuery = {}): Promise<PagedResult<AdminUserOut>> {
const qs = toQueryString({ q: query.q, limit: query.limit, offset: query.offset })
return apiRequest<PagedResult<AdminUserOut>>(`/admin/users${qs}`)
}
/** Сменить роль и/или блокировку пользователя. 409 — попытка изменить свою учётную запись. */
export async function updateAdminUser(id: string, payload: AdminUserUpdatePayload): Promise<AdminUserOut> {
return apiRequest<AdminUserOut>(`/admin/users/${id}`, { method: 'PATCH', body: payload })
}
/**
* Создать пользователя от имени администратора. 201 — email
* считается подтверждённым сразу. 409 — email уже зарегистрирован,
* 404 `team_not_found` — указанная команда не существует.
*/
export async function createAdminUser(payload: AdminUserCreateIn): Promise<AdminUserOut> {
return apiRequest<AdminUserOut>('/admin/users', { method: 'POST', body: payload })
}
/** Карточка профиля пользователя — открывается по клику на имя в таблице «Пользователи». */
export async function getAdminUser(id: string): Promise<AdminUserDetailOut> {
return apiRequest<AdminUserDetailOut>(`/admin/users/${id}`)
}
/** Загрузить аватар пользователя от имени администратора. 413/415 — лимиты размера/типа файла. */
export async function uploadAdminUserAvatar(id: string, file: File): Promise<AdminUserDetailOut> {
const form = new FormData()
form.append('file', file)
return apiRequest<AdminUserDetailOut>(`/admin/users/${id}/avatar`, { method: 'POST', body: form })
}
/** Текущие настройки инстанса. */
export async function getAdminSettings(): Promise<SettingsOut> {
return apiRequest<SettingsOut>('/admin/settings')
}
/** Частично обновить настройки инстанса. 400 — недоступный уровень AI/невалидная таймзона. */
export async function updateAdminSettings(payload: SettingsUpdateIn): Promise<SettingsOut> {
return apiRequest<SettingsOut>('/admin/settings', { method: 'PUT', body: payload })
}
/** Список команд для админки — с пагинацией. */
export async function listAdminTeams(query: AdminTeamQuery = {}): Promise<PagedResult<TeamOut>> {
const qs = toQueryString({ limit: query.limit, offset: query.offset })
return apiRequest<PagedResult<TeamOut>>(`/admin/teams${qs}`)
}
/** Создать команду. 409 — имя уже занято. */
export async function createAdminTeam(name: string): Promise<TeamOut> {
return apiRequest<TeamOut>('/admin/teams', { method: 'POST', body: { name } })
}
/** Переименовать команду. 409 — имя уже занято. */
export async function renameAdminTeam(id: string, name: string): Promise<TeamOut> {
return apiRequest<TeamOut>(`/admin/teams/${id}`, { method: 'PATCH', body: { name } })
}
/** Удалить команду навсегда. */
export async function deleteAdminTeam(id: string): Promise<void> {
await apiRequest(`/admin/teams/${id}`, { method: 'DELETE' })
}

81
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,81 @@
/**
* API-функции аутентификации.
*/
import { apiRequest } from '@/api/client'
export interface RegisterPayload {
email: string
name_user: string
password: string
/** Выбранная команда — только если выбор команды включён в настройках инстанса. */
team_id?: string | null
}
/** Команда, доступная для выбора на экране регистрации. */
export interface RegistrationTeamOption {
id: string
name: string
}
/** Параметры экрана регистрации — зависят от настройки инстанса `registration_team_choice`. */
export interface RegistrationOptions {
team_choice_enabled: boolean
teams: RegistrationTeamOption[]
/** Эталонный домен почты при включённой верификации, иначе `null`. */
email_domain: string | null
}
export interface CurrentUser {
id: string
email: string
name_user: string
role: string
/** URL загруженного аватара, `null` — показывается заглушка с инициалами (см. `Avatar`). */
avatar_url: string | null
team_id: string | null
team_name: string | null
}
export interface TokenResponse {
access_token: string
token_type: string
}
/** Регистрация нового пользователя. 409 — если email уже занят. */
export async function register(payload: RegisterPayload): Promise<void> {
await apiRequest('/auth/register', { method: 'POST', body: payload, skipAuthRefresh: true })
}
/**
* Параметры экрана регистрации — публичный эндпоинт, доступен без авторизации.
* Если выбор команды отключён в настройках инстанса, `teams` пуст.
*/
export async function getRegistrationOptions(): Promise<RegistrationOptions> {
return apiRequest<RegistrationOptions>('/auth/registration-options', { skipAuthRefresh: true })
}
/** Подтверждение почты по токену из письма. 400 — если ссылка просрочена/недействительна. */
export async function verifyEmail(token: string): Promise<void> {
await apiRequest('/auth/verify-email', { method: 'POST', body: { token }, skipAuthRefresh: true })
}
/**
* Вход по логину/паролю. Backend ждёт OAuth2-form-data (username=email).
* Refresh-токен приходит httpOnly cookie — тело ответа содержит только access.
*/
export async function login(email: string, password: string): Promise<TokenResponse> {
const form = new URLSearchParams()
form.set('username', email)
form.set('password', password)
return apiRequest<TokenResponse>('/auth/token', { method: 'POST', body: form, skipAuthRefresh: true })
}
/** Явный выход — инвалидирует refresh-сессию на backend. */
export async function logout(): Promise<void> {
await apiRequest('/auth/logout', { method: 'POST' })
}
/** Текущий пользователь по access-токену. */
export async function getMe(): Promise<CurrentUser> {
return apiRequest<CurrentUser>('/users/me')
}

143
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,143 @@
/**
* Базовый API-клиент VidConf.
*
* - Access-токен подставляется из authStore (память, не localStorage).
* - На 401 выполняется один silent-refresh (POST /auth/refresh,
* credentials: 'include' — сессия читается из httpOnly-cookie) и повтор
* исходного запроса. Если refresh не удался — access-токен сбрасывается и
* выполняется редирект на /login.
* - Параллельные 401 схлопываются в один refresh-запрос (refreshPromise).
*/
import { authStore } from '@/auth/authStore'
const API_BASE = '/api/v1'
/** Ошибка API с HTTP-статусом и разобранным телом ответа (если это JSON). */
export class ApiError extends Error {
readonly status: number
readonly detail: unknown
constructor(status: number, detail: unknown, message?: string) {
super(message ?? `Ошибка API: ${status}`)
this.name = 'ApiError'
this.status = status
this.detail = detail
}
}
/**
* Извлекает машиночитаемый код ошибки из тела ответа FastAPI
* (`{"detail": "some_code"}`) — используется для различения причин 403/409
* (напр. "password_required" vs "invalid_password").
*/
export function errorDetail(err: unknown): string | undefined {
if (err instanceof ApiError && err.detail && typeof err.detail === 'object' && 'detail' in err.detail) {
const value = (err.detail as { detail: unknown }).detail
return typeof value === 'string' ? value : undefined
}
return undefined
}
interface RequestOptions extends Omit<RequestInit, 'body'> {
body?: unknown
/** Не пытаться делать silent-refresh при 401 (используется самим refresh-запросом). */
skipAuthRefresh?: boolean
}
let refreshPromise: Promise<boolean> | null = null
/**
* Выполняет silent-refresh access-токена через httpOnly refresh-cookie.
* Возвращает true при успехе. Параллельные вызовы переиспользуют один запрос.
*/
export async function refreshAccessToken(): Promise<boolean> {
if (!refreshPromise) {
refreshPromise = (async () => {
try {
const response = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
if (!response.ok) return false
const data = (await response.json()) as { access_token: string }
authStore.setAccessToken(data.access_token)
return true
} catch {
return false
} finally {
refreshPromise = null
}
})()
}
return refreshPromise
}
/** Редирект на страницу входа при невосстановимой потере сессии. */
function redirectToLogin(): void {
authStore.setAccessToken(null)
if (window.location.pathname !== '/login') {
window.location.assign('/login')
}
}
async function parseBody(response: Response): Promise<unknown> {
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
/**
* Базовый fetch-запрос к API с автоподстановкой access-токена и
* silent-refresh на 401.
*/
export async function apiRequest<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> {
const { body, skipAuthRefresh, headers, ...rest } = options
const doFetch = async (): Promise<Response> => {
const finalHeaders = new Headers(headers)
const token = authStore.getAccessToken()
if (token) finalHeaders.set('Authorization', `Bearer ${token}`)
let finalBody: BodyInit | undefined
if (body !== undefined && !(body instanceof URLSearchParams) && !(body instanceof FormData)) {
finalHeaders.set('Content-Type', 'application/json')
finalBody = JSON.stringify(body)
} else if (body instanceof URLSearchParams) {
finalHeaders.set('Content-Type', 'application/x-www-form-urlencoded')
finalBody = body
} else if (body instanceof FormData) {
finalBody = body
}
return fetch(`${API_BASE}${path}`, {
...rest,
headers: finalHeaders,
body: finalBody,
credentials: 'include',
})
}
let response = await doFetch()
if (response.status === 401 && !skipAuthRefresh) {
const refreshed = await refreshAccessToken()
if (refreshed) {
response = await doFetch()
} else {
redirectToLogin()
throw new ApiError(401, null, 'Сессия истекла')
}
}
if (!response.ok) {
const detail = await parseBody(response)
throw new ApiError(response.status, detail)
}
if (response.status === 204) return undefined as T
return (await parseBody(response)) as T
}

View File

@@ -0,0 +1,237 @@
/**
* API-функции конференций: предустановленных комнат нет, конференции
* создаются динамически.
*
* Все даты — UTC ISO-8601 со 'Z' (в БД и API только UTC).
*/
import { apiRequest } from '@/api/client'
/** Жизненный цикл конференции (см. ADR-001; `draft` не используется). */
export type ConferenceStatus = 'scheduled' | 'active' | 'ended'
/** Тип повторения закреплённой конференции. */
export type RecurrenceType = 'weekly' | 'biweekly' | 'monthly' | 'every_n_days'
/**
* Режим рассылки саммари конференции — переопределение дефолта
* инстанса (`null` = «по умолчанию», см. `SettingsOut.summary_recipients`
* в `src/api/admin.ts`).
*/
export type SummaryRecipientsMode = 'all' | 'owner'
/**
* Правило повторения закреплённой конференции — форма 1:1 с pydantic-моделью
* `backend/services/recurrence.py::RecurrenceRule` (истина о форме — там).
* Поля, специфичные для типа (`weekdays` для weekly/biweekly, `day_of_month`
* для monthly, `interval_days` для every_n_days), обязательны только для
* своего типа — см. валидацию на backend.
*/
export interface ConferenceRecurrence {
type: RecurrenceType
/** 0 (понедельник) .. 6 (воскресенье) — обязателен для weekly/biweekly. */
weekdays?: number[]
/** 1..31 — обязателен для monthly (переносится на последний день короткого месяца). */
day_of_month?: number
/** >= 1 — обязателен для every_n_days. */
interval_days?: number
/** Дата первого вхождения серии (YYYY-MM-DD) — якорь отсчёта для biweekly/every_n_days. */
anchor_date: string
/** Локальное время начала вхождения, формат HH:MM. */
time_local: string
/** IANA-таймзона (браузера пользователя на момент создания/редактирования). */
timezone: string
/** Длительность вхождения в минутах — дублирует верхнеуровневое поле конференции (того требует модель RecurrenceRule). */
duration_minutes: number
}
/** Данные для входа в LiveKit-комнату конференции. */
export interface ConferenceJoinData {
livekit_url: string
token: string
room_name: string
conference_id: string
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
chat_enabled: boolean
}
/**
* Приглашённый участник конференции — либо
* зарегистрированный пользователь (`user_id` заполнен), либо внешний гость
* по email (`user_id === null`). Организатор всегда присутствует первым
* элементом (`is_organizer: true`), добавляется backend'ом автоматически.
*/
export interface InviteeOut {
user_id: string | null
email: string
name: string | null
avatar_url: string | null
is_organizer: boolean
}
/**
* Приглашаемый участник в теле создания/обновления — либо `user_id`
* зарегистрированного пользователя, либо `email` внешнего гостя (ровно одно
* из полей). Организатора указывать не нужно — backend добавляет его
* автоматически и обязательно.
*/
export type InviteeIn = { user_id: string } | { email: string }
/** Конференция (ответ API). */
export interface ConferenceOut {
id: string
number: string
slug: string
title: string | null
status: ConferenceStatus
is_pinned: boolean
is_closed: boolean
scheduled_at: string | null
duration_minutes: number | null
recurrence: ConferenceRecurrence | null
next_occurrence: string | null
created_at: string
/** `null` — используется дефолт инстанса (см. `SettingsOut.summary_recipients`). */
summary_recipients: SummaryRecipientsMode | null
/** Присутствует только у мгновенной конференции — сразу входим, не дожидаясь отдельного join. */
join?: ConferenceJoinData
/** Владелец (организатор) конференции. */
owner_id: string
/** Организатор ли конференции текущий пользователь — относительно него же скрываются кнопки правки/удаления. */
is_owner: boolean
organizer_name: string | null
/** Присутствует ТОЛЬКО в ответе детального эндпоинта (`GET /conferences/{id}`, owner|admin) — списковые эндпоинты (`/conferences/my`, календарь) его не возвращают. */
participants?: InviteeOut[]
}
/** Тело запроса на создание конференции. Без `scheduled_at` — мгновенная (ответ сразу содержит `join`). */
export interface ConferenceCreatePayload {
title?: string
/** UTC ISO-8601. */
scheduled_at?: string
duration_minutes?: number
is_pinned?: boolean
recurrence?: ConferenceRecurrence
is_closed?: boolean
password?: string
/** `null`/не передано — дефолт инстанса; `'all'`/`'owner'` — явное переопределение. */
summary_recipients?: SummaryRecipientsMode | null
/** `undefined` — состав не менялся (не отправлять поле); `null`/массив — полная замена состава (кроме организатора, добавляется backend'ом сам). */
participants?: InviteeIn[] | null
}
/**
* Краткие сведения о конференции по ссылке/номеру — для экрана подключения
* (JoinPage). Для `status === 'ended'` (ADR-001) backend отдаёт минимальный
* ответ — `is_closed`/`requires_password` отсутствуют (незачем: повторный
* вход недоступен независимо от закрытости).
*/
export interface ConferenceResolveOut {
id: string
title: string | null
status: ConferenceStatus
is_closed?: boolean
requires_password?: boolean
}
/** Тело входа авторизованного пользователя в конференцию. */
export interface ConferenceJoinPayload {
password?: string
}
/** Тело гостевого входа — представление обязательно, email факультативен (для рассылки саммари). */
export interface ConferenceGuestJoinPayload {
display_name: string
email?: string
password?: string
}
/** Тело частичного обновления конференции — те же поля, что и при создании, все опциональны. */
export type ConferenceUpdatePayload = Partial<ConferenceCreatePayload>
/** Одно вхождение (развёрнутое по recurrence или разовое) в календарной выборке. */
export interface OccurrenceOut {
conference_id: string
title: string | null
/** UTC ISO-8601. */
starts_at: string
/** UTC ISO-8601. */
ends_at: string
number: string
slug: string
is_pinned: boolean
is_closed: boolean
}
/**
* Создать конференцию. Без `scheduled_at` тела — мгновенная: создатель сразу
* входит (см. `ConferenceOut.join`).
*/
export async function createConference(payload: ConferenceCreatePayload = {}): Promise<ConferenceOut> {
return apiRequest<ConferenceOut>('/conferences', { method: 'POST', body: payload })
}
/**
* Резолв конференции по ссылке (slug) или номеру — публичный эндпоинт,
* доступен без авторизации (нужен и гостю до входа). 404 — не найдена/недоступна
* (в т.ч. незакреплённая, уже завершившаяся).
*/
export async function resolveConference(query: string): Promise<ConferenceResolveOut> {
const params = new URLSearchParams({ q: query })
return apiRequest<ConferenceResolveOut>(`/conferences/resolve?${params.toString()}`, {
skipAuthRefresh: true,
})
}
/**
* Вход авторизованного пользователя в конференцию.
* 403 (`password_required`/`invalid_password`), 410 (`conference_ended`).
*/
export async function joinConference(id: string, payload?: ConferenceJoinPayload): Promise<ConferenceJoinData> {
return apiRequest<ConferenceJoinData>(`/conferences/${id}/join`, { method: 'POST', body: payload })
}
/** Гостевой вход без аккаунта — те же коды ошибок, что и `joinConference`. */
export async function guestJoinConference(
id: string,
payload: ConferenceGuestJoinPayload,
): Promise<ConferenceJoinData> {
return apiRequest<ConferenceJoinData>(`/conferences/${id}/guest-join`, {
method: 'POST',
body: payload,
skipAuthRefresh: true,
})
}
/** Список «моих» конференций — закреплённые (повторяющиеся) и предстоящие разовые владельца. */
export async function getMyConferences(): Promise<ConferenceOut[]> {
return apiRequest<ConferenceOut[]>('/conferences/my')
}
/**
* Вхождения конференций в календарном диапазоне `[from, to]` (обе границы —
* UTC ISO, диапазон не длиннее 62 дней — ограничение backend).
*/
export async function getCalendarOccurrences(fromIso: string, toIso: string): Promise<OccurrenceOut[]> {
const params = new URLSearchParams({ from: fromIso, to: toIso })
return apiRequest<OccurrenceOut[]>(`/conferences/calendar?${params.toString()}`)
}
/** Частично обновить конференцию (название, время, повторение, закрытость и т.п.). */
export async function updateConference(id: string, payload: ConferenceUpdatePayload): Promise<ConferenceOut> {
return apiRequest<ConferenceOut>(`/conferences/${id}`, { method: 'PATCH', body: payload })
}
/** Удалить конференцию навсегда. 409 — конференция сейчас идёт (`status: 'active'`). */
export async function deleteConference(id: string): Promise<void> {
await apiRequest(`/conferences/${id}`, { method: 'DELETE' })
}
/**
* Детали одной конференции с полным составом участников (`participants`).
* Доступно только владельцу или admin — иначе 403.
* Используется ховер-карточкой (§F2) и формой редактирования для подгрузки
* состава участников.
*/
export async function getConference(id: string): Promise<ConferenceOut> {
return apiRequest<ConferenceOut>(`/conferences/${id}`)
}

View File

@@ -0,0 +1,26 @@
/**
* Клиент публичного эндпоинта здоровья инстанса (`GET /api/health`).
*
* Живёт вне `/api/v1` (см. `backend/api/health.py` — маршрут зарегистрирован
* без версионного префикса) и не требует авторизации, поэтому запрос идёт
* напрямую через `fetch`, в обход `apiRequest`/`API_BASE` из `client.ts`.
*
* Поле `version` (версия инстанса из `VERSION`/`VIDCONF_VERSION`) —
* опционально: если backend его не отдаёт, футер админки просто не
* показывает версию.
*/
export interface HealthOut {
status: string
db: boolean
redis: boolean
version?: string
}
/** Запросить статус здоровья инстанса. */
export async function getHealth(): Promise<HealthOut> {
const response = await fetch('/api/health', { credentials: 'include' })
if (!response.ok) {
throw new Error(`Ошибка API: ${response.status}`)
}
return (await response.json()) as HealthOut
}

80
frontend/src/api/users.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* API-функции профиля текущего пользователя и поиска зарегистрированных
* пользователей (участники конференций, профиль).
*/
import { apiRequest } from '@/api/client'
import type { CurrentUser } from '@/api/auth'
/** Элемент результата поиска пользователей (пикер участников конференции) — без email, только для отображения/выбора по `id`. */
export interface UserSearchResult {
id: string
display_name: string
avatar_url: string | null
}
/** Поиск зарегистрированных пользователей по имени/email — для пикера участников конференции. */
export async function searchUsers(query: string, limit = 8): Promise<UserSearchResult[]> {
const params = new URLSearchParams({ q: query, limit: String(limit) })
return apiRequest<UserSearchResult[]>(`/users?${params.toString()}`)
}
/** Команда в справочнике команд (страница профиля). */
export interface TeamOption {
id: string
name: string
}
/**
* Справочник команд для страницы профиля — аутентифицированный эндпоинт,
* не зависит от тумблера `registration_team_choice` (в отличие от
* `getRegistrationOptions` в `api/auth.ts`, который гасит список команд при
* выключенном выборе команды на регистрации).
*/
export async function listTeams(): Promise<TeamOption[]> {
return apiRequest<TeamOption[]>('/teams')
}
/** Тело правки собственного профиля — ФИО и/или команда, email не редактируется. */
export interface UpdateMePayload {
name_user?: string
/** `null` — снять с команды. */
team_id?: string | null
}
/** Частично обновить собственный профиль. */
export async function updateMe(payload: UpdateMePayload): Promise<CurrentUser> {
return apiRequest<CurrentUser>('/users/me', { method: 'PATCH', body: payload })
}
/**
* Загрузить аватар текущего пользователя. 200 — полный обновлённый профиль.
* 413 — файл больше 2 МБ, 415 — недопустимый тип (разрешены jpeg/png/webp);
* оба кода обрабатываются вызывающей стороной через `ApiError.status`.
*/
export async function uploadMyAvatar(file: File): Promise<CurrentUser> {
const form = new FormData()
form.append('file', file)
return apiRequest<CurrentUser>('/users/me/avatar', { method: 'POST', body: form })
}
/** Удалить аватар текущего пользователя — 204 без тела; вызывающая сторона сама перечитывает профиль (см. `refreshUser`). */
export async function deleteMyAvatar(): Promise<void> {
await apiRequest('/users/me/avatar', { method: 'DELETE' })
}
/** Тело смены пароля текущего пользователя. */
export interface PasswordChangePayload {
current_password: string
new_password: string
}
/**
* Сменить пароль текущего пользователя. 204 без тела. 400 `invalid_current_password` —
* неверный текущий пароль, 422 — новый пароль короче 8 символов.
*
* Refresh-сессии сознательно не отзываются backend'ом (минимальный дифф —
* см. ADR-005, отзыв запланирован вместе со сбросом пароля по email в v0.1.0).
*/
export async function changePassword(payload: PasswordChangePayload): Promise<void> {
await apiRequest('/users/me/password', { method: 'POST', body: payload })
}

View File

@@ -0,0 +1,72 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { getMe, login as apiLogin, logout as apiLogout, type CurrentUser } from '@/api/auth'
import { authStore } from '@/auth/authStore'
import { refreshAccessToken } from '@/api/client'
import { AuthContext, type AuthContextValue, type AuthStatus } from '@/auth/authContext'
/**
* Провайдер сессии пользователя.
* При монтировании приложения пытается восстановить сессию через
* silent-refresh (httpOnly refresh-cookie), т.к. access-токен в памяти
* теряется при перезагрузке страницы.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<CurrentUser | null>(null)
const [status, setStatus] = useState<AuthStatus>('loading')
useEffect(() => {
let cancelled = false
async function bootstrap() {
const restored = await refreshAccessToken()
if (cancelled) return
if (!restored) {
setStatus('unauthenticated')
return
}
try {
const me = await getMe()
if (cancelled) return
setUser(me)
setStatus('authenticated')
} catch {
if (!cancelled) setStatus('unauthenticated')
}
}
void bootstrap()
return () => {
cancelled = true
}
}, [])
const login = useCallback(async (email: string, password: string) => {
const token = await apiLogin(email, password)
authStore.setAccessToken(token.access_token)
const me = await getMe()
setUser(me)
setStatus('authenticated')
}, [])
const logout = useCallback(async () => {
try {
await apiLogout()
} finally {
authStore.setAccessToken(null)
setUser(null)
setStatus('unauthenticated')
}
}, [])
const refreshUser = useCallback(async () => {
const me = await getMe()
setUser(me)
}, [])
const value = useMemo<AuthContextValue>(
() => ({ user, status, login, logout, refreshUser }),
[user, status, login, logout, refreshUser],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

View File

@@ -0,0 +1,18 @@
import type { ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '@/auth/useAuth'
/**
* Route-guard админки: доступ только у пользователей с ролью
* `admin`. Оборачивается ВНУТРИ `RequireAuth` (сессия уже гарантированно
* восстановлена и `user` заполнен) — без сессии сюда не попасть.
*/
export function RequireAdmin({ children }: { children: ReactNode }) {
const { user } = useAuth()
if (user?.role !== 'admin') {
return <Navigate to="/lobby" replace />
}
return <>{children}</>
}

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '@/auth/useAuth'
/**
* Обёртка для приватных маршрутов: пока идёт восстановление сессии —
* показывает лоадер, без сессии — редиректит на /login (сохраняя исходный
* путь, чтобы вернуться туда после входа).
*/
export function RequireAuth({ children }: { children: ReactNode }) {
const { status } = useAuth()
const location = useLocation()
if (status === 'loading') {
return (
<div className="app-loader" role="status" aria-live="polite">
Загрузка сессии
</div>
)
}
if (status === 'unauthenticated') {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}

View File

@@ -0,0 +1,26 @@
import { createContext } from 'react'
import type { CurrentUser } from '@/api/auth'
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'
export interface AuthContextValue {
user: CurrentUser | null
status: AuthStatus
/** Вход по email/паролю: сохраняет access-токен и загружает профиль. */
login: (email: string, password: string) => Promise<void>
/** Выход: инвалидирует refresh-сессию на backend и очищает локальное состояние. */
logout: () => Promise<void>
/**
* Перечитать профиль (`GET /users/me`) и обновить контекст — вызывается
* после правки профиля/аватара (страница `/profile`), чтобы имя и
* аватар в топбаре обновились без перезагрузки страницы.
*/
refreshUser: () => Promise<void>
}
/**
* Контекст сессии пользователя. Вынесен в отдельный файл (не в AuthProvider.tsx),
* чтобы файл компонента экспортировал только React-компонент — это нужно
* react-refresh для корректного fast refresh в dev-режиме.
*/
export const AuthContext = createContext<AuthContextValue | null>(null)

View File

@@ -0,0 +1,41 @@
import { useSyncExternalStore } from 'react'
/**
* Модуль-стор access-токена JWT.
*
* Токен хранится ТОЛЬКО в памяти процесса (в переменной модуля), никогда —
* в localStorage/sessionStorage: это защищает его от чтения через XSS.
* Refresh-токен — httpOnly cookie, им управляет браузер, здесь он не виден
* и не хранится.
*
* При перезагрузке страницы access-токен теряется — сессия восстанавливается
* через silent-refresh в AuthProvider при старте приложения.
*/
type Listener = () => void
let accessToken: string | null = null
const listeners = new Set<Listener>()
function emitChange(): void {
for (const listener of listeners) listener()
}
export const authStore = {
getAccessToken(): string | null {
return accessToken
},
setAccessToken(token: string | null): void {
accessToken = token
emitChange()
},
subscribe(listener: Listener): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
/** Реактивный доступ к текущему access-токену (для компонентов, которым это нужно). */
export function useAccessToken(): string | null {
return useSyncExternalStore(authStore.subscribe, authStore.getAccessToken)
}

View File

@@ -0,0 +1,9 @@
import { useContext } from 'react'
import { AuthContext, type AuthContextValue } from '@/auth/authContext'
/** Хук доступа к текущей сессии пользователя. Должен использоваться внутри AuthProvider. */
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth должен использоваться внутри <AuthProvider>')
return ctx
}

View File

@@ -0,0 +1,450 @@
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, Clock, Lock, Mail, Pencil, Play, Repeat, Search, Send, Trash2, X } from 'lucide-react'
import {
deleteAdminConference,
listAdminConferences,
sendConferenceInvitations,
updateAdminConference,
type AdminConferenceOut,
type AdminConferenceUpdatePayload,
} from '@/api/admin'
import type { ConferenceStatus, SummaryRecipientsMode } from '@/api/conferences'
import { ApiError } from '@/api/client'
import { useToast } from '@/components/ui/ToastProvider'
import { formatLocalDateTime, localDateTimeToIso, toLocalDateTimeParts } from '@/lib/localTime'
import { formatRecurrenceSummary } from '@/lib/recurrenceFormat'
const PAGE_SIZE = 10
const STATUS_FILTERS: { value: ConferenceStatus | 'all'; label: string }[] = [
{ value: 'all', label: 'Все' },
{ value: 'scheduled', label: 'Запланированные' },
{ value: 'active', label: 'Активные' },
{ value: 'ended', label: 'Завершённые' },
]
function StatusBadge({ conference }: { conference: AdminConferenceOut }) {
if (conference.status === 'active') {
return (
<span className="badge badge-free">
<Play style={{ width: 12, height: 12 }} aria-hidden="true" />
Идёт сейчас
</span>
)
}
if (conference.status === 'ended') {
return (
<span className="badge badge-locked">
<Check style={{ width: 12, height: 12 }} aria-hidden="true" />
Завершена
</span>
)
}
return (
<span className="badge badge-booked">
<Clock style={{ width: 12, height: 12 }} aria-hidden="true" />
Запланирована
</span>
)
}
/** Форма быстрого редактирования конференции из админки (title/время/пароль/рассылка). */
function EditConferenceModal({ conference, onClose }: { conference: AdminConferenceOut; onClose: () => void }) {
const queryClient = useQueryClient()
const toast = useToast()
const initialSlot = conference.scheduled_at ? toLocalDateTimeParts(conference.scheduled_at) : { date: '', time: '' }
const [title, setTitle] = useState(conference.title ?? '')
const [dateStr, setDateStr] = useState(initialSlot.date)
const [timeStr, setTimeStr] = useState(initialSlot.time)
const [durationMinutes, setDurationMinutes] = useState(conference.duration_minutes ?? 30)
const [isClosed, setIsClosed] = useState(conference.is_closed)
const [password, setPassword] = useState('')
const [summaryRecipients, setSummaryRecipients] = useState<'' | SummaryRecipientsMode>(
conference.summary_recipients ?? '',
)
const mutation = useMutation({
mutationFn: (payload: AdminConferenceUpdatePayload) => updateAdminConference(conference.id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'conferences'] })
toast.show('Изменения сохранены', 'success')
onClose()
},
onError: () => toast.show('Не удалось сохранить изменения', 'error'),
})
function handleSubmit() {
const payload: AdminConferenceUpdatePayload = {
title: title.trim(),
duration_minutes: durationMinutes,
is_closed: isClosed,
summary_recipients: summaryRecipients === '' ? null : summaryRecipients,
}
if (dateStr && timeStr) payload.scheduled_at = localDateTimeToIso(dateStr, timeStr)
if (isClosed && password) payload.password = password
mutation.mutate(payload)
}
return (
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={onClose}>
<div className="modal-panel is-wide" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2>Редактирование конференции</h2>
<button type="button" className="modal-close" aria-label="Закрыть" onClick={onClose}>
<X style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
{conference.is_pinned && conference.recurrence && (
<p className="field-hint" style={{ marginBottom: 'var(--space-4)' }}>
<Repeat style={{ width: 14, height: 14, verticalAlign: '-2px' }} aria-hidden="true" /> Закреплена:{' '}
{formatRecurrenceSummary(conference.recurrence)} повторение редактируется владельцем в календаре.
</p>
)}
<form
onSubmit={(e) => {
e.preventDefault()
handleSubmit()
}}
>
<div className="field">
<label htmlFor="admin-conf-title">Название</label>
<input id="admin-conf-title" type="text" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={255} />
</div>
{!conference.is_pinned && (
<div className="field-row">
<div className="field">
<label htmlFor="admin-conf-date">Дата</label>
<input id="admin-conf-date" type="date" value={dateStr} onChange={(e) => setDateStr(e.target.value)} />
</div>
<div className="field">
<label htmlFor="admin-conf-time">Время</label>
<input id="admin-conf-time" type="time" value={timeStr} onChange={(e) => setTimeStr(e.target.value)} />
</div>
</div>
)}
<div className="field">
<label htmlFor="admin-conf-duration">Длительность, мин</label>
<input
id="admin-conf-duration"
type="number"
min={5}
step={5}
value={durationMinutes}
onChange={(e) => setDurationMinutes(Number(e.target.value) || 0)}
/>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Закрытая по паролю</strong>
<span>Вход только по паролю, включая гостей</span>
</div>
<label className="switch">
<input type="checkbox" checked={isClosed} onChange={(e) => setIsClosed(e.target.checked)} />
<span className="slider" />
</label>
</div>
{isClosed && (
<div className="field">
<label htmlFor="admin-conf-password">
<Lock style={{ width: 14, height: 14 }} aria-hidden="true" />
Новый пароль (оставьте пустым, чтобы не менять)
</label>
<input
id="admin-conf-password"
type="password"
placeholder="Минимум 4 символа"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
)}
<div className="field">
<label htmlFor="admin-conf-recipients">Рассылка саммари</label>
<select
id="admin-conf-recipients"
value={summaryRecipients}
onChange={(e) => setSummaryRecipients(e.target.value as '' | SummaryRecipientsMode)}
>
<option value="">По умолчанию (настройки инстанса)</option>
<option value="all">Всем участникам</option>
<option value="owner">Только организатору</option>
</select>
</div>
<div className="modal-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Отмена
</button>
<button type="submit" className="btn btn-primary" disabled={mutation.isPending}>
{mutation.isPending ? 'Сохраняем…' : 'Сохранить'}
</button>
</div>
</form>
</div>
</div>
)
}
/** Модалка ручной рассылки .ics-приглашений по конкретной конференции. */
function InviteModal({ conference, onClose }: { conference: AdminConferenceOut; onClose: () => void }) {
const [emailsText, setEmailsText] = useState('')
const toast = useToast()
const mutation = useMutation({
mutationFn: () => {
const emails = emailsText
.split(/[\n,;]+/)
.map((e) => e.trim())
.filter(Boolean)
return sendConferenceInvitations(conference.id, emails.length > 0 ? emails : undefined)
},
onSuccess: () => {
toast.show('Рассылка приглашений поставлена в очередь', 'success')
onClose()
},
onError: () => toast.show('Не удалось поставить рассылку', 'error'),
})
return (
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={onClose}>
<div className="modal-panel is-wide" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2>Рассылка приглашений</h2>
<button type="button" className="modal-close" aria-label="Закрыть" onClick={onClose}>
<X style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
<p className="field-hint" style={{ marginBottom: 'var(--space-4)' }}>
«{conference.title ?? conference.number}» оставьте поле пустым, чтобы разослать по умолчанию (владельцу и
участникам прошлых сеансов).
</p>
<div className="field">
<label htmlFor="admin-invite-emails">Адреса (через запятую или с новой строки)</label>
<textarea
id="admin-invite-emails"
rows={5}
placeholder="i.sokolov@company.ru&#10;o.panina@company.ru"
value={emailsText}
onChange={(e) => setEmailsText(e.target.value)}
/>
</div>
<div className="modal-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Отмена
</button>
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={() => mutation.mutate()}>
<Send style={{ width: 16, height: 16 }} aria-hidden="true" />
{mutation.isPending ? 'Отправляем…' : 'Отправить'}
</button>
</div>
</div>
</div>
)
}
/**
* Вкладка «Конференции» админки — список запланированных/закреплённых
* конференций с поиском, фильтром по статусу, пагинацией, редактированием,
* удалением и ручной рассылкой приглашений (design/mockups/admin.html,
* блок «table-card», адаптированный под сущность «конференция»).
*/
export function AdminConferencesTab() {
const [statusFilter, setStatusFilter] = useState<ConferenceStatus | 'all'>('all')
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0)
const [editingConference, setEditingConference] = useState<AdminConferenceOut | null>(null)
const [invitingConference, setInvitingConference] = useState<AdminConferenceOut | null>(null)
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
const queryClient = useQueryClient()
const toast = useToast()
// Дебаунс поискового ввода — не дёргаем API на каждое нажатие клавиши.
useEffect(() => {
const timer = setTimeout(() => {
setSearch(searchInput.trim())
setOffset(0)
}, 300)
return () => clearTimeout(timer)
}, [searchInput])
const { data, isLoading } = useQuery({
queryKey: ['admin', 'conferences', statusFilter, search, offset],
queryFn: () =>
listAdminConferences({
status: statusFilter === 'all' ? undefined : statusFilter,
q: search || undefined,
limit: PAGE_SIZE,
offset,
}),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminConference(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'conferences'] })
toast.show('Конференция удалена', 'success')
setConfirmingDeleteId(null)
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
toast.show('Нельзя удалить — конференция сейчас идёт', 'error')
} else {
toast.show('Не удалось удалить конференцию', 'error')
}
setConfirmingDeleteId(null)
},
})
const items = data?.items ?? []
const total = data?.total ?? 0
return (
<>
<div className="section-tabs-inline">
{STATUS_FILTERS.map((f) => (
<button
key={f.value}
type="button"
className={`chip${statusFilter === f.value ? ' is-active' : ''}`}
onClick={() => {
setStatusFilter(f.value)
setOffset(0)
}}
>
{f.label}
</button>
))}
</div>
<div className="toolbar-row">
<div className="search-wrap">
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
<input
type="text"
placeholder="Поиск по названию или номеру…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
<span className="toolbar-count">
{isLoading ? 'Загрузка…' : `Показано ${items.length ? offset + 1 : 0}${offset + items.length} из ${total}`}
</span>
</div>
<div className="table-card">
<table>
<thead>
<tr>
<th>Конференция</th>
<th>Владелец</th>
<th>Статус</th>
<th>Тип</th>
<th>Начало</th>
<th></th>
</tr>
</thead>
<tbody>
{items.length === 0 && !isLoading && (
<tr>
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-ink-500)' }}>
Конференции не найдены
</td>
</tr>
)}
{items.map((conf) => (
<tr key={conf.id}>
<td>
{conf.is_closed && <Lock style={{ width: 14, height: 14, marginRight: 6, verticalAlign: '-2px' }} aria-hidden="true" />}
{conf.title ?? 'Без названия'}
<span style={{ display: 'block', font: 'var(--text-caption)', color: 'var(--color-ink-500)' }}>
{conf.number}
</span>
</td>
<td>
{conf.owner_name ?? '—'}
{conf.owner_email && (
<span style={{ display: 'block', font: 'var(--text-caption)', color: 'var(--color-ink-500)' }}>
{conf.owner_email}
</span>
)}
</td>
<td>
<StatusBadge conference={conf} />
</td>
<td>
{conf.is_pinned ? (
<span className="badge badge-locked">
<Repeat style={{ width: 12, height: 12 }} aria-hidden="true" />
Закреплена
</span>
) : (
'Разовая'
)}
</td>
<td>{conf.next_occurrence ? formatLocalDateTime(conf.next_occurrence) : conf.scheduled_at ? formatLocalDateTime(conf.scheduled_at) : '—'}</td>
<td>
{confirmingDeleteId === conf.id ? (
<div className="row-actions">
<button
type="button"
className="icon-btn danger"
aria-label="Подтвердить удаление"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(conf.id)}
>
<Check style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<button type="button" className="icon-btn" aria-label="Отмена" onClick={() => setConfirmingDeleteId(null)}>
<X style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
) : (
<div className="row-actions">
<button type="button" className="icon-btn" aria-label="Рассылка приглашений" onClick={() => setInvitingConference(conf)}>
<Mail style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<button type="button" className="icon-btn" aria-label="Редактировать" onClick={() => setEditingConference(conf)}>
<Pencil style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<button type="button" className="icon-btn danger" aria-label="Удалить" onClick={() => setConfirmingDeleteId(conf.id)}>
<Trash2 style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div className="pagination-row">
<button type="button" className="btn btn-secondary" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>
Назад
</button>
<button
type="button"
className="btn btn-secondary"
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset(offset + PAGE_SIZE)}
>
Далее
</button>
</div>
)}
{editingConference && <EditConferenceModal conference={editingConference} onClose={() => setEditingConference(null)} />}
{invitingConference && <InviteModal conference={invitingConference} onClose={() => setInvitingConference(null)} />}
</>
)
}

View File

@@ -0,0 +1,278 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react'
import { getAdminSettings, updateAdminSettings, type AiLevel, type SettingsOut, type SettingsUpdateIn } from '@/api/admin'
import type { SummaryRecipientsMode } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client'
import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider'
const SUMMARY_RECIPIENTS_OPTIONS = [
{ value: 'all', label: 'Всем участникам' },
{ value: 'owner', label: 'Только организатору' },
]
const AI_LEVEL_LABEL: Record<AiLevel, string> = {
min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)',
medium: 'Средний',
max: 'Максимальный',
}
/**
* Вкладка «Настройки» админки — раздела нет в макете `admin.html`, построена
* по паттерну того же макета (`section-tab`/`table-card` → карточки-секции
* с `toggle-row`/radio).
* Тумблер «Транскрибация и суммаризация» — единый переключатель AI-модуля
* (см. `SettingsOut.transcription_enabled`).
*
* Форма (`AdminSettingsForm`) вынесена отдельно и монтируется только после
* загрузки `data` — локальное состояние инициализируется прямо из пропсов
* при монтировании, без `useEffect`-синхронизации (react-hooks/set-state-in-effect).
*/
export function AdminSettingsTab() {
const { data, isLoading } = useQuery({ queryKey: ['admin', 'settings'], queryFn: getAdminSettings })
if (isLoading || !data) {
return <p className="field-hint">Загрузка настроек</p>
}
return <AdminSettingsForm data={data} />
}
function AdminSettingsForm({ data }: { data: SettingsOut }) {
const queryClient = useQueryClient()
const toast = useToast()
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level)
const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients)
const [timezone, setTimezone] = useState(data.display_timezone)
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
const [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '')
const mutation = useMutation({
mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload),
onSuccess: (result) => {
queryClient.setQueryData(['admin', 'settings'], result)
toast.show('Настройки сохранены', 'success')
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 400) {
toast.show(errorDetail(err) ?? 'Недоступное значение — проверьте уровень AI, таймзону и домен почты', 'error')
} else {
toast.show('Не удалось сохранить настройки', 'error')
}
},
})
function handleSave() {
mutation.mutate({
chat_enabled: chatEnabled,
transcription_enabled: aiEnabled,
ai_level: aiLevel,
summary_recipients: recipients,
display_timezone: timezone.trim(),
registration_team_choice: teamChoiceEnabled,
registration_email_domain_enabled: domainVerificationEnabled,
registration_email_domain: emailDomain.trim() || null,
})
}
return (
<div className="settings-grid">
<section className="settings-card">
<h2>Модули</h2>
<p className="desc">Включение и отключение возможностей инстанса переключатели плагинов из поставки.</p>
<div className="settings-card-body settings-card-body--spread">
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
<div className="toggle-copy">
<strong>Чат конференции</strong>
<span>Текстовый чат внутри комнаты для участников</span>
</div>
<label className="switch">
<input type="checkbox" checked={chatEnabled} onChange={(e) => setChatEnabled(e.target.checked)} />
<span className="slider" />
</label>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Транскрибация и суммаризация (AI)</strong>
<span>Батч-обработка записи после конференции: расшифровка речи и саммари</span>
{aiEnabled && !data.transcription_queue_served && (
<span className="ai-level-reason">
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" />
AI включён, но обработка очереди транскрибации недоступна сеансы не будут обработаны
</span>
)}
</div>
<label className="switch">
<input type="checkbox" checked={aiEnabled} onChange={(e) => setAiEnabled(e.target.checked)} />
<span className="slider" />
</label>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Разрешить выбор команды при регистрации</strong>
<span>На экране регистрации появится поле «Команда» со списком команд инстанса</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={teamChoiceEnabled}
onChange={(e) => setTeamChoiceEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Верификация по домену почты</strong>
<span>Регистрация только с email эталонного домена (см. плитку «Эталон mail-домена»)</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={domainVerificationEnabled}
onChange={(e) => setDomainVerificationEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
{/*
Анонс записи конференций. Чекбокс не имеет состояния и не
участвует в сохранении формы — функция появится в одной из
следующих версий, здесь только заглушка-анонс в общем стиле
«Модулей».
*/}
<div className="toggle-row">
<div className="toggle-copy">
<strong>Возможность записи конференций</strong>
<span>Функция появится в ближайших версиях</span>
</div>
<label className="switch">
<input type="checkbox" checked={false} disabled readOnly />
<span className="slider" />
</label>
</div>
</div>
</section>
<div className="settings-stack">
<section className="settings-card">
<h2>Уровень AI</h2>
<p className="desc">Качество распознавания и суммаризации определяет размер моделей и требования к железу.</p>
<div className="settings-card-body">
<div className="ai-level-list">
{data.ai_levels.map((levelStatus) => (
<label
key={levelStatus.level}
className={`ai-level-option${!levelStatus.available ? ' is-disabled' : ''}`}
>
<input
type="radio"
name="ai-level"
checked={aiLevel === levelStatus.level}
disabled={!levelStatus.available || !aiEnabled}
onChange={() => setAiLevel(levelStatus.level)}
/>
<div>
<strong>{AI_LEVEL_LABEL[levelStatus.level]}</strong>
{!levelStatus.available && levelStatus.reason && (
<span className="ai-level-reason">
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" />
{levelStatus.reason}
</span>
)}
</div>
</label>
))}
</div>
</div>
</section>
<section className="settings-card">
<h2>Эталон mail-домена</h2>
<p className="desc">Домен, с которым сверяется email при регистрации, если включена верификация в «Модулях».</p>
<div className="settings-card-body settings-card-body--center">
<div className="field" style={{ marginBottom: 0 }}>
<label htmlFor="settings-email-domain">Домен корпоративной почты</label>
<input
id="settings-email-domain"
type="text"
placeholder="company.ru"
value={emailDomain}
disabled={!domainVerificationEnabled}
onChange={(e) => setEmailDomain(e.target.value)}
/>
</div>
</div>
</section>
</div>
<section className="settings-card">
<h2>Рассылка саммари</h2>
<p className="desc">Кому по умолчанию отправляется письмо с саммари после конференции (переопределяется в карточке конференции).</p>
<div className="settings-card-body settings-card-body--center">
<div className="field" style={{ marginBottom: 0 }}>
<label id="settings-recipients-label" htmlFor="settings-recipients">
Получатели по умолчанию
</label>
<Select
id="settings-recipients"
aria-labelledby="settings-recipients-label"
value={recipients}
onChange={(v) => setRecipients(v as SummaryRecipientsMode)}
options={SUMMARY_RECIPIENTS_OPTIONS}
/>
</div>
</div>
</section>
<section className="settings-card">
<h2>Часовой пояс отображения</h2>
<p className="desc">IANA-таймзона для времени в письмах и .ics-приглашениях (в БД время всегда хранится в UTC).</p>
<div className="settings-card-body settings-card-body--center">
<div className="field" style={{ marginBottom: 0 }}>
<label htmlFor="settings-timezone">Таймзона</label>
<input
id="settings-timezone"
type="text"
list="timezone-options"
placeholder="Europe/Moscow"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
/>
<datalist id="timezone-options">
<option value="Europe/Moscow" />
<option value="Europe/Kaliningrad" />
<option value="Europe/Samara" />
<option value="Asia/Yekaterinburg" />
<option value="Asia/Novosibirsk" />
<option value="Asia/Krasnoyarsk" />
<option value="Asia/Irkutsk" />
<option value="Asia/Vladivostok" />
<option value="UTC" />
</datalist>
</div>
</div>
</section>
<div className="settings-actions">
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,241 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, Pencil, Plus, Trash2, X } from 'lucide-react'
import { createAdminTeam, deleteAdminTeam, listAdminTeams, renameAdminTeam, type TeamOut } from '@/api/admin'
import { ApiError } from '@/api/client'
import { useToast } from '@/components/ui/ToastProvider'
const PAGE_SIZE = 20
/**
* Вкладка «Команды» админки — список команд с датой создания, добавление,
* инлайн-переименование и удаление с подтверждением. В макете
* `design/mockups/admin.html` отдельного экрана для команд нет — вкладка
* построена по паттерну `table-card`/`toolbar-row`/`row-actions` того же
* макета и `admin.css` (тот же приём, что и вкладка «Настройки»,
* см. комментарий в `AdminPage.tsx`).
*/
export function AdminTeamsTab() {
const [offset, setOffset] = useState(0)
const [newName, setNewName] = useState('')
const [editingId, setEditingId] = useState<string | null>(null)
const [editingName, setEditingName] = useState('')
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
const queryClient = useQueryClient()
const toast = useToast()
const { data, isLoading } = useQuery({
queryKey: ['admin', 'teams', offset],
queryFn: () => listAdminTeams({ limit: PAGE_SIZE, offset }),
})
function invalidate() {
queryClient.invalidateQueries({ queryKey: ['admin', 'teams'] })
}
const createMutation = useMutation({
mutationFn: (name: string) => createAdminTeam(name),
onSuccess: () => {
invalidate()
toast.show('Команда добавлена', 'success')
setNewName('')
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
toast.show('Команда с таким названием уже есть', 'error')
} else {
toast.show('Не удалось добавить команду', 'error')
}
},
})
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => renameAdminTeam(id, name),
onSuccess: () => {
invalidate()
toast.show('Команда переименована', 'success')
setEditingId(null)
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
toast.show('Команда с таким названием уже есть', 'error')
} else {
toast.show('Не удалось переименовать команду', 'error')
}
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminTeam(id),
onSuccess: () => {
invalidate()
toast.show('Команда удалена', 'success')
setConfirmingDeleteId(null)
},
onError: () => {
toast.show('Не удалось удалить команду', 'error')
setConfirmingDeleteId(null)
},
})
function handleCreate() {
const name = newName.trim()
if (!name) return
createMutation.mutate(name)
}
function startEditing(team: TeamOut) {
setEditingId(team.id)
setEditingName(team.name)
}
function handleRenameSubmit(id: string) {
const name = editingName.trim()
if (!name) return
renameMutation.mutate({ id, name })
}
const items = data?.items ?? []
const total = data?.total ?? 0
return (
<>
<div className="toolbar-row">
<form
className="team-add-form"
onSubmit={(e) => {
e.preventDefault()
handleCreate()
}}
>
<input
type="text"
placeholder="Название новой команды…"
value={newName}
maxLength={255}
onChange={(e) => setNewName(e.target.value)}
aria-label="Название новой команды"
/>
<button type="submit" className="btn btn-primary" disabled={createMutation.isPending || !newName.trim()}>
<Plus style={{ width: 16, height: 16 }} aria-hidden="true" />
Добавить команду
</button>
</form>
<span className="toolbar-count">
{isLoading ? 'Загрузка…' : `Показано ${items.length ? offset + 1 : 0}${offset + items.length} из ${total}`}
</span>
</div>
<div className="table-card">
<table>
<thead>
<tr>
<th>Название</th>
<th>Создана</th>
<th></th>
</tr>
</thead>
<tbody>
{items.length === 0 && !isLoading && (
<tr>
<td colSpan={3} style={{ textAlign: 'center', color: 'var(--color-ink-500)' }}>
Команды не найдены
</td>
</tr>
)}
{items.map((team) => (
<tr key={team.id}>
<td>
{editingId === team.id ? (
<form
className="team-rename-form"
onSubmit={(e) => {
e.preventDefault()
handleRenameSubmit(team.id)
}}
>
<input
type="text"
value={editingName}
maxLength={255}
autoFocus
onChange={(e) => setEditingName(e.target.value)}
aria-label="Новое название команды"
/>
</form>
) : (
team.name
)}
</td>
<td>{new Date(team.created_at).toLocaleDateString('ru-RU')}</td>
<td>
{editingId === team.id ? (
<div className="row-actions">
<button
type="button"
className="icon-btn"
aria-label="Сохранить название"
disabled={renameMutation.isPending}
onClick={() => handleRenameSubmit(team.id)}
>
<Check style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<button type="button" className="icon-btn" aria-label="Отмена" onClick={() => setEditingId(null)}>
<X style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
) : confirmingDeleteId === team.id ? (
<div className="row-actions">
<button
type="button"
className="icon-btn danger"
aria-label="Подтвердить удаление"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(team.id)}
>
<Check style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<button type="button" className="icon-btn" aria-label="Отмена" onClick={() => setConfirmingDeleteId(null)}>
<X style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
) : (
<div className="row-actions">
<button type="button" className="icon-btn" aria-label="Переименовать" onClick={() => startEditing(team)}>
<Pencil style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<button
type="button"
className="icon-btn danger"
aria-label="Удалить"
onClick={() => setConfirmingDeleteId(team.id)}
>
<Trash2 style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div className="pagination-row">
<button type="button" className="btn btn-secondary" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>
Назад
</button>
<button
type="button"
className="btn btn-secondary"
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset(offset + PAGE_SIZE)}
>
Далее
</button>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,148 @@
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { X } from 'lucide-react'
import { createAdminUser, listAdminTeams } from '@/api/admin'
import { ApiError, errorDetail } from '@/api/client'
import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider'
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте (как в AdminUsersTab). */
const TEAMS_LIMIT = 200
interface AdminUserCreateDialogProps {
onClose: () => void
}
/**
* Диалог «Добавить пользователя» в админке (вкладка «Пользователи») —
* по образцу `AdminUserProfileDialog`. ФИО, email,
* команда (справочник команд) и пароль; создание сразу с подтверждённой
* почтой (`email_verified = true` выставляет backend). Роль остаётся
* дефолтной («Пользователь») — сменить её можно потом из таблицы.
*/
export function AdminUserCreateDialog({ onClose }: AdminUserCreateDialogProps) {
const toast = useToast()
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [teamId, setTeamId] = useState('')
const [emailError, setEmailError] = useState<string | null>(null)
const { data: teamsData } = useQuery({
queryKey: ['admin', 'teams', 'all'],
queryFn: () => listAdminTeams({ limit: TEAMS_LIMIT, offset: 0 }),
})
const teams = teamsData?.items ?? []
const mutation = useMutation({
mutationFn: () =>
createAdminUser({ name_user: name.trim(), email: email.trim(), password, team_id: teamId === '' ? null : teamId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
toast.show('Пользователь создан', 'success')
onClose()
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
setEmailError('Email уже зарегистрирован')
} else if (err instanceof ApiError && err.status === 404 && errorDetail(err) === 'team_not_found') {
toast.show('Выбранная команда не найдена — обновите список команд', 'error')
} else {
toast.show('Не удалось создать пользователя', 'error')
}
},
})
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setEmailError(null)
mutation.mutate()
}
const canSubmit = name.trim().length > 0 && email.trim().length > 0 && password.length >= 8
return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="admin-user-create-title" onClick={onClose}>
<div className="modal-panel" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2 id="admin-user-create-title">Добавить пользователя</h2>
<button type="button" className="modal-close" aria-label="Закрыть" onClick={onClose}>
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="admin-create-name">ФИО</label>
<input
id="admin-create-name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={255}
/>
</div>
<div className={`field${emailError ? ' has-error' : ''}`}>
<label htmlFor="admin-create-email">Email</label>
<input
id="admin-create-email"
type="email"
required
value={email}
onChange={(e) => {
setEmail(e.target.value)
setEmailError(null)
}}
/>
{emailError && (
<p className="field-hint" style={{ color: 'var(--color-danger)' }}>
{emailError}
</p>
)}
</div>
<div className="field">
<label id="admin-create-team-label" htmlFor="admin-create-team">
Команда
</label>
<Select
id="admin-create-team"
aria-labelledby="admin-create-team-label"
value={teamId}
onChange={setTeamId}
options={[{ value: '', label: 'Без команды' }, ...teams.map((t) => ({ value: t.id, label: t.name }))]}
/>
</div>
<div className="field">
<label htmlFor="admin-create-password">Пароль</label>
<input
id="admin-create-password"
type="password"
autoComplete="new-password"
required
minLength={8}
placeholder="Минимум 8 символов"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="field-hint">Пользователь сможет сменить пароль после входа, в своём профиле</p>
</div>
<div className="modal-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Отмена
</button>
<button type="submit" className="btn btn-primary" disabled={!canSubmit || mutation.isPending}>
{mutation.isPending ? 'Создаём…' : 'Создать'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,219 @@
import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Ban, CheckCircle2, Upload, X } from 'lucide-react'
import {
getAdminUser,
listAdminTeams,
updateAdminUser,
uploadAdminUserAvatar,
type AdminUserOut,
} from '@/api/admin'
import { ApiError } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { Avatar } from '@/components/ui/Avatar'
import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider'
/** Разрешённые типы/лимит размера файла аватара — те же ограничения, что и на ProfilePage/backend. */
const ALLOWED_AVATAR_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const MAX_AVATAR_BYTES = 2 * 1024 * 1024
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте (как в AdminUsersTab). */
const TEAMS_LIMIT = 200
interface AdminUserProfileDialogProps {
userId: string
onClose: () => void
}
/**
* Карточка профиля пользователя в админке — открывается
* кликом по имени в таблице «Пользователи» (`AdminUsersTab.tsx`). Те же поля,
* что и в собственном профиле (`ProfilePage.tsx`) плюс уже существующие
* админ-действия «роль»/«блокировка» — та же логика прав (собственная учётная
* запись администратора защищена от самоизменения роли/блокировки).
*/
export function AdminUserProfileDialog({ userId, onClose }: AdminUserProfileDialogProps) {
const { user: currentUser } = useAuth()
const toast = useToast()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [avatarError, setAvatarError] = useState<string | null>(null)
const [name, setName] = useState<string | null>(null)
const { data, isLoading } = useQuery({ queryKey: ['admin', 'users', userId], queryFn: () => getAdminUser(userId) })
const { data: teamsData } = useQuery({
queryKey: ['admin', 'teams', 'all'],
queryFn: () => listAdminTeams({ limit: TEAMS_LIMIT, offset: 0 }),
})
const teams = teamsData?.items ?? []
const isSelf = userId === currentUser?.id
function invalidate() {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
}
const mutation = useMutation({
mutationFn: (payload: Parameters<typeof updateAdminUser>[1]) => updateAdminUser(userId, payload),
onSuccess: () => {
invalidate()
queryClient.invalidateQueries({ queryKey: ['admin', 'users', userId] })
toast.show('Изменения сохранены', 'success')
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
toast.show('Нельзя изменить собственную учётную запись', 'error')
} else {
toast.show('Не удалось сохранить изменения', 'error')
}
},
})
const avatarMutation = useMutation({
mutationFn: (file: File) => uploadAdminUserAvatar(userId, file),
onSuccess: () => {
invalidate()
queryClient.invalidateQueries({ queryKey: ['admin', 'users', userId] })
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')
},
})
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)
}
function handleRoleChange(target: AdminUserOut, role: 'admin' | 'user') {
if (role === target.role) return
mutation.mutate({ role })
}
function handleToggleBlock(target: AdminUserOut) {
mutation.mutate({ is_blocked: !target.is_blocked })
}
function handleTeamChange(teamId: string) {
mutation.mutate({ team_id: teamId === '' ? null : teamId })
}
function handleSaveName() {
if (name === null) return
mutation.mutate({ name_user: name.trim() })
}
const displayName = name ?? data?.name_user ?? ''
return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="admin-user-dialog-title" onClick={onClose}>
<div className="modal-panel" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2 id="admin-user-dialog-title">Профиль пользователя</h2>
<button type="button" className="modal-close" aria-label="Закрыть" onClick={onClose}>
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
{isLoading || !data ? (
<p className="field-hint">Загрузка</p>
) : (
<>
<div className="profile-avatar-block">
<Avatar name={data.name_user} avatarUrl={data.avatar_url} size={72} />
<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>
<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>
<div className="field">
<label htmlFor="admin-user-name">ФИО</label>
<input id="admin-user-name" type="text" value={displayName} onChange={(e) => setName(e.target.value)} maxLength={255} />
</div>
<div className="field">
<label htmlFor="admin-user-email">Email</label>
<input id="admin-user-email" type="email" value={data.email} disabled readOnly />
</div>
<div className="field">
<label id="admin-user-team-label" htmlFor="admin-user-team">
Команда
</label>
<Select
id="admin-user-team"
aria-labelledby="admin-user-team-label"
value={data.team_id ?? ''}
onChange={handleTeamChange}
disabled={mutation.isPending}
options={[{ value: '', label: 'Без команды' }, ...teams.map((t) => ({ value: t.id, label: t.name }))]}
/>
</div>
<div className="field">
<label id="admin-user-role-label" htmlFor="admin-user-role">
Роль
</label>
<Select
id="admin-user-role"
aria-labelledby="admin-user-role-label"
value={data.role}
onChange={(v) => handleRoleChange(data, v as 'admin' | 'user')}
disabled={isSelf || mutation.isPending}
options={[
{ value: 'user', label: 'Пользователь' },
{ value: 'admin', label: 'Администратор' },
]}
/>
</div>
<div className="modal-actions">
<button type="button" className="btn btn-primary" onClick={handleSaveName} disabled={mutation.isPending || !displayName.trim()}>
Сохранить ФИО
</button>
<button
type="button"
className="btn btn-secondary"
disabled={isSelf || mutation.isPending}
title={isSelf ? 'Нельзя изменить собственную учётную запись' : undefined}
onClick={() => handleToggleBlock(data)}
>
{data.is_blocked ? (
<CheckCircle2 style={{ width: 16, height: 16 }} aria-hidden="true" />
) : (
<Ban style={{ width: 16, height: 16 }} aria-hidden="true" />
)}
{data.is_blocked ? 'Разблокировать' : 'Заблокировать'}
</button>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,233 @@
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Ban, CheckCircle2, Search, UserPlus } from 'lucide-react'
import { listAdminTeams, listAdminUsers, updateAdminUser, type AdminUserOut } from '@/api/admin'
import { ApiError } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { AdminUserCreateDialog } from '@/components/admin/AdminUserCreateDialog'
import { AdminUserProfileDialog } from '@/components/admin/AdminUserProfileDialog'
import { Avatar } from '@/components/ui/Avatar'
import { useToast } from '@/components/ui/ToastProvider'
const PAGE_SIZE = 10
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте. */
const TEAMS_LIMIT = 200
/**
* Вкладка «Пользователи» админки — список с поиском, пагинацией, сменой
* роли, блокировкой и командой (design/mockups/admin.html,
* «table-card»/«user-cell»). Собственная учётная запись администратора
* защищена от самоизменения на уровне UI (disabled) и backend (409 — на
* случай гонки в двух вкладках) — но это касается только роли и блокировки:
* смену собственной команды запрет не затрагивает, селект «Команда» для
* своей строки не дизейблится.
*
* Клик по имени пользователя открывает карточку
* профиля (`AdminUserProfileDialog`) — те же поля, что в собственном
* профиле, плюс существующие действия роль/блокировка.
*/
export function AdminUsersTab() {
const { user: currentUser } = useAuth()
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0)
const [viewingUserId, setViewingUserId] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const queryClient = useQueryClient()
const toast = useToast()
useEffect(() => {
const timer = setTimeout(() => {
setSearch(searchInput.trim())
setOffset(0)
}, 300)
return () => clearTimeout(timer)
}, [searchInput])
const { data, isLoading } = useQuery({
queryKey: ['admin', 'users', search, offset],
queryFn: () => listAdminUsers({ q: search || undefined, limit: PAGE_SIZE, offset }),
})
const { data: teamsData } = useQuery({
queryKey: ['admin', 'teams', 'all'],
queryFn: () => listAdminTeams({ limit: TEAMS_LIMIT, offset: 0 }),
})
const teams = teamsData?.items ?? []
const mutation = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: Parameters<typeof updateAdminUser>[1] }) => updateAdminUser(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
toast.show('Нельзя изменить собственную учётную запись', 'error')
} else {
toast.show('Не удалось сохранить изменения', 'error')
}
},
})
function handleRoleChange(target: AdminUserOut, role: 'admin' | 'user') {
if (role === target.role) return
mutation.mutate({ id: target.id, payload: { role } })
}
function handleToggleBlock(target: AdminUserOut) {
mutation.mutate({ id: target.id, payload: { is_blocked: !target.is_blocked } })
}
function handleTeamChange(target: AdminUserOut, teamId: string) {
const nextTeamId = teamId === '' ? null : teamId
if (nextTeamId === target.team_id) return
mutation.mutate({ id: target.id, payload: { team_id: nextTeamId } })
}
const items = data?.items ?? []
const total = data?.total ?? 0
return (
<>
<div className="toolbar-row">
<div className="toolbar-left">
<div className="search-wrap">
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
<input
type="text"
placeholder="Поиск по имени или email…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
<UserPlus style={{ width: 16, height: 16 }} aria-hidden="true" />
Добавить пользователя
</button>
</div>
<span className="toolbar-count">
{isLoading ? 'Загрузка…' : `Показано ${items.length ? offset + 1 : 0}${offset + items.length} из ${total}`}
</span>
</div>
<div className="table-card">
<table>
<thead>
<tr>
<th>Пользователь</th>
<th>Роль</th>
<th>Команда</th>
<th>Регистрация</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody>
{items.length === 0 && !isLoading && (
<tr>
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-ink-500)' }}>
Пользователи не найдены
</td>
</tr>
)}
{items.map((u) => {
const isSelf = u.id === currentUser?.id
return (
<tr key={u.id}>
<td>
<div className="user-cell">
<Avatar name={u.name_user} avatarUrl={u.avatar_url} size={32} />
<div>
<button type="button" className="user-cell-name" onClick={() => setViewingUserId(u.id)}>
{u.name_user}
</button>
<span>{u.email}</span>
</div>
</div>
</td>
<td>
<select
value={u.role}
disabled={isSelf || mutation.isPending}
onChange={(e) => handleRoleChange(u, e.target.value as 'admin' | 'user')}
aria-label="Роль пользователя"
>
<option value="user">Пользователь</option>
<option value="admin">Администратор</option>
</select>
</td>
<td>
<select
value={u.team_id ?? ''}
disabled={mutation.isPending}
onChange={(e) => handleTeamChange(u, e.target.value)}
aria-label="Команда пользователя"
>
<option value="">Без команды</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
</td>
<td>{new Date(u.created_at).toLocaleDateString('ru-RU')}</td>
<td>
{u.is_blocked ? (
<span className="badge badge-busy">
<Ban style={{ width: 12, height: 12 }} aria-hidden="true" />
Заблокирован
</span>
) : (
<span className="badge badge-free">
<CheckCircle2 style={{ width: 12, height: 12 }} aria-hidden="true" />
Активен
</span>
)}
</td>
<td>
<div className="row-actions">
<button
type="button"
className={`icon-btn${u.is_blocked ? '' : ' danger'}`}
aria-label={u.is_blocked ? 'Разблокировать' : 'Заблокировать'}
disabled={isSelf || mutation.isPending}
title={isSelf ? 'Нельзя изменить собственную учётную запись' : undefined}
onClick={() => handleToggleBlock(u)}
>
{u.is_blocked ? (
<CheckCircle2 style={{ width: 16, height: 16 }} aria-hidden="true" />
) : (
<Ban style={{ width: 16, height: 16 }} aria-hidden="true" />
)}
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div className="pagination-row">
<button type="button" className="btn btn-secondary" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>
Назад
</button>
<button
type="button"
className="btn btn-secondary"
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset(offset + PAGE_SIZE)}
>
Далее
</button>
</div>
)}
{viewingUserId && <AdminUserProfileDialog userId={viewingUserId} onClose={() => setViewingUserId(null)} />}
{creating && <AdminUserCreateDialog onClose={() => setCreating(false)} />}
</>
)
}

View File

@@ -0,0 +1,51 @@
import type { ReactNode } from 'react'
import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle'
import '@/styles/auth.css'
/**
* Общая двухколоночная разметка экранов auth (бренд-панель + форма),
* см. design/mockups/auth.html. Переключатель темы — плавающей плашкой в
* правом верхнем углу (см. design/mockups/dark/login.html): у экрана нет
* общего топбара, панель бренда и форма — раздельные колонки.
*/
export function AuthLayout({ children }: { children: ReactNode }) {
return (
<div className="layout">
<ThemeToggle className="theme-toggle--floating" />
<aside className="brand-panel">
<div className="brand-mark">
<LogoMark /> VidConf
</div>
<div className="brand-copy">
<p className="eyebrow">[ SELF-HOSTED ВИДЕОКОНФЕРЕНЦИИ ]</p>
<h1 className="brand-headline">
Ваши встречи.
<br />
Ваша инфраструктура.
</h1>
<p className="brand-sub">
Видео, записи и AI-саммари обрабатываются на ваших серверах - данные не уходят за пределы вашей
инфраструктуры. Гости подключаются по ссылке или номеру, без регистрации.
</p>
<div className="brand-stats">
<div className="stat-glass">
<strong>100%</strong>
<span>данных - в вашем контуре</span>
</div>
<div className="stat-glass">
<strong>AI-саммари</strong>
<span>на почту после встречи</span>
</div>
</div>
</div>
<p className="hint-msg" style={{ position: 'relative', zIndex: 1 }}>
© 2026 VidConf · self-hosted
</p>
</aside>
<main className="form-panel">{children}</main>
</div>
)
}

View File

@@ -0,0 +1,245 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import FullCalendar from '@fullcalendar/react'
import type { DatesSetArg, EventContentArg, EventInput, EventMountArg } from '@fullcalendar/core'
import type { EventClickArg, EventHoveringArg } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import ruLocale from '@fullcalendar/core/locales/ru'
import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Lock, Play, Repeat } from 'lucide-react'
import type { OccurrenceOut } from '@/api/conferences'
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
/*
* Стабильные (модульные, не пересоздаваемые на каждый рендер) значения для
* пропсов FullCalendar. `@fullcalendar/react` при любом изменении identity
* пропса-опции (массива/объекта/функции) дёргает внутренний `resetOptions`,
* который заново вызывает `datesSet` — если тот, в свою очередь, меняет
* состояние компонента без проверки «а изменилось ли значение», получается
* бесконечный цикл setState → рендер → новые identity пропсов → resetOptions
* → datesSet → setState (баг гейта, 2026-07-16: /calendar рендерился пустым
* экраном с «Maximum update depth exceeded» в консоли). Поэтому: массивы
* `plugins`/`hiddenDays` и функция `renderEventContent` — константы модуля
* (не зависят от пропсов/состояния), а `events`/колбэки, зависящие от
* пропсов, — обёрнуты в `useMemo`/`useCallback` ниже.
*/
const CALENDAR_PLUGINS = [dayGridPlugin, interactionPlugin]
const HIDDEN_DAYS_BUSINESS_WEEK: number[] = [0, 6]
const HIDDEN_DAYS_NONE: number[] = []
/**
* Хранилище отписок focusin/focusout по DOM-элементу чипа:
* ховер-карточка по клавиатурному фокусу) — элементы событий FullCalendar
* рендерятся вне React-дерева, поэтому слушатели вешаются нативно в
* `eventDidMount`/снимаются в `eventWillUnmount`; `WeakMap` не мешает сборке
* мусора при повторном рендере событий.
*/
const eventFocusCleanups = new WeakMap<HTMLElement, () => void>()
export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth'
interface ConferenceCalendarProps {
view: CalendarViewMode
onViewChange: (view: CalendarViewMode) => void
occurrences: OccurrenceOut[]
isLoading: boolean
/** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */
onRangeChange: (fromIso: string, toIso: string) => void
onOccurrenceClick: (occurrence: OccurrenceOut) => void
}
type BadgeKind = 'scheduled' | 'pinned' | 'live' | 'ended'
function occurrenceBadge(occ: OccurrenceOut, start: Date | null, end: Date | null): { kind: BadgeKind; label: string } {
const now = new Date()
if (start && end && now >= start && now <= end) return { kind: 'live', label: 'Идёт сейчас' }
if (end && now > end) return { kind: 'ended', label: 'Завершена' }
if (occ.is_pinned) return { kind: 'pinned', label: 'Закреплена' }
return { kind: 'scheduled', label: 'Запланирована' }
}
const BADGE_ICON: Record<BadgeKind, typeof Clock> = {
scheduled: Clock,
pinned: Repeat,
live: Play,
ended: CheckCircle2,
}
/** Не зависит от пропсов/состояния компонента — модульная константа-функция (см. комментарий выше про стабильность identity). */
function renderEventContent(arg: EventContentArg) {
const occ = arg.event.extendedProps.occurrence as OccurrenceOut
const start = arg.event.start
const end = arg.event.end
const badge = occurrenceBadge(occ, start, end)
const Icon = BADGE_ICON[badge.kind]
const timeLabel =
start && end
? `${start.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}${end.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`
: ''
return (
<div>
<span className={`conf-badge ${badge.kind}`}>
<Icon style={{ width: 11, height: 11 }} aria-hidden="true" />
{badge.label}
</span>
<span className="chip-time">{timeLabel}</span>
<span className="chip-title">
{occ.is_closed && <Lock className="chip-lock" aria-hidden="true" />}
{arg.event.title}
</span>
</div>
)
}
/**
* Обёртка над FullCalendar (см. design/mockups/calendar.html): недельная
* (только будни, 5 колонок) / месячная сетка вхождений конференций, свой
* тулбар (вид, навигация по датам) вместо встроенного headerToolbar.
* События рендерятся как «чипы» (`.conf-chip`) со статус-бейджем §4.6
* DESIGN_SYSTEM.md — вместо позиционирования по времени (timeGrid), т.к.
* менеджер конференций показывает список вхождений по дням, не занятость
* переговорной по часам (переговорных комнат в модели нет).
*/
export function ConferenceCalendar({
view,
onViewChange,
occurrences,
isLoading,
onRangeChange,
onOccurrenceClick,
}: ConferenceCalendarProps) {
const calendarRef = useRef<FullCalendar | null>(null)
const titleRef = useRef<HTMLSpanElement | null>(null)
// Ховер-карточка конференции — состояние держим внутри
// компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже
// оставались стабильными по identity между рендерами (см. комментарий в
// начале файла про resetOptions/бесконечный цикл при нестабильных пропсах).
const [hoverTarget, setHoverTarget] = useState<{ occurrence: OccurrenceOut; anchorEl: HTMLElement } | null>(null)
const handleEventMouseEnter = useCallback((arg: EventHoveringArg) => {
setHoverTarget({ occurrence: arg.event.extendedProps.occurrence as OccurrenceOut, anchorEl: arg.el })
}, [])
const handleEventMouseLeave = useCallback((arg: EventHoveringArg) => {
setHoverTarget((prev) => (prev && prev.anchorEl === arg.el ? null : prev))
}, [])
// Клавиатурная доступность ховер-карточки: чипы FullCalendar рендерятся
// самой библиотекой вне React-дерева, поэтому фокус отслеживаем нативными
// слушателями focusin/focusout (не React onFocus/onBlur).
const handleEventDidMount = useCallback((arg: EventMountArg) => {
arg.el.tabIndex = 0
const occurrence = arg.event.extendedProps.occurrence as OccurrenceOut
const handleFocusIn = () => setHoverTarget({ occurrence, anchorEl: arg.el })
const handleFocusOut = () => setHoverTarget((prev) => (prev && prev.anchorEl === arg.el ? null : prev))
arg.el.addEventListener('focusin', handleFocusIn)
arg.el.addEventListener('focusout', handleFocusOut)
eventFocusCleanups.set(arg.el, () => {
arg.el.removeEventListener('focusin', handleFocusIn)
arg.el.removeEventListener('focusout', handleFocusOut)
})
}, [])
const handleEventWillUnmount = useCallback((arg: EventMountArg) => {
eventFocusCleanups.get(arg.el)?.()
eventFocusCleanups.delete(arg.el)
}, [])
useEffect(() => {
calendarRef.current?.getApi().changeView(view)
}, [view])
function goPrev() {
calendarRef.current?.getApi().prev()
}
function goNext() {
calendarRef.current?.getApi().next()
}
function goToday() {
calendarRef.current?.getApi().today()
}
// Мемоизировано по `occurrences` — иначе на каждый рендер создавался бы
// новый массив-`events` той же длины/содержимого, и FullCalendar считал бы
// его «изменившейся опцией» (см. комментарий у модульных констант выше).
const events: EventInput[] = useMemo(
() =>
occurrences.map((occ) => ({
id: `${occ.conference_id}:${occ.starts_at}`,
title: occ.title ?? 'Конференция без названия',
start: occ.starts_at,
end: occ.ends_at,
classNames: ['conf-chip'],
extendedProps: { occurrence: occ },
})),
[occurrences],
)
const handleEventClick = useCallback(
(info: EventClickArg) => {
onOccurrenceClick(info.event.extendedProps.occurrence as OccurrenceOut)
},
[onOccurrenceClick],
)
const handleDatesSet = useCallback(
(arg: DatesSetArg) => {
const api = calendarRef.current?.getApi()
if (api && titleRef.current) titleRef.current.textContent = api.view.title
onRangeChange(arg.start.toISOString(), arg.end.toISOString())
},
[onRangeChange],
)
return (
<section className="week-card">
<div className="week-toolbar">
<div className="date-nav">
<button type="button" className="date-nav-arrow" aria-label="Предыдущий период" onClick={goPrev}>
<ChevronLeft className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<span ref={titleRef} />
<button type="button" className="date-nav-arrow" aria-label="Следующий период" onClick={goNext}>
<ChevronRight className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<button type="button" className="btn btn-secondary" onClick={goToday}>
Сегодня
</button>
</div>
<div className="view-toggle" role="group" aria-label="Вид календаря">
<button type="button" className={view === 'dayGridWeek' ? 'is-active' : ''} onClick={() => onViewChange('dayGridWeek')}>
Неделя
</button>
<button type="button" className={view === 'dayGridMonth' ? 'is-active' : ''} onClick={() => onViewChange('dayGridMonth')}>
Месяц
</button>
</div>
</div>
<div className="vc-calendar" aria-busy={isLoading}>
<FullCalendar
ref={calendarRef}
plugins={CALENDAR_PLUGINS}
initialView={view}
headerToolbar={false}
locale={ruLocale}
timeZone="local"
firstDay={1}
hiddenDays={view === 'dayGridWeek' ? HIDDEN_DAYS_BUSINESS_WEEK : HIDDEN_DAYS_NONE}
height="auto"
dayMaxEvents={false}
events={events}
eventContent={renderEventContent}
eventClick={handleEventClick}
datesSet={handleDatesSet}
eventMouseEnter={handleEventMouseEnter}
eventMouseLeave={handleEventMouseLeave}
eventDidMount={handleEventDidMount}
eventWillUnmount={handleEventWillUnmount}
/>
</div>
<ConferenceHoverCard conferenceId={hoverTarget?.occurrence.conference_id ?? null} anchorEl={hoverTarget?.anchorEl ?? null} />
</section>
)
}

View File

@@ -0,0 +1,529 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Calendar as CalendarIcon, Check, Clock, Lock } from 'lucide-react'
import {
createConference,
deleteConference,
getConference,
updateConference,
type ConferenceCreatePayload,
type ConferenceOut,
type ConferenceRecurrence,
type InviteeIn,
type RecurrenceType,
type SummaryRecipientsMode,
} from '@/api/conferences'
import { ApiError } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { Select } from '@/components/ui/Select'
import { ParticipantsPicker, type SelectedParticipant } from '@/components/calendar/ParticipantsPicker'
import { useToast } from '@/components/ui/ToastProvider'
import { browserTimeZone, localDateTimeToIso, toLocalDateTimeParts } from '@/lib/localTime'
import { WEEKDAY_SHORT, formatPreviewHint, previewNextDates } from '@/lib/recurrenceFormat'
const SUMMARY_RECIPIENTS_OPTIONS = [
{ value: '', label: 'По умолчанию' },
{ value: 'all', label: 'Всем участникам' },
{ value: 'owner', label: 'Только организатору' },
]
interface ConferenceFormCardProps {
/** `undefined` — карточка в режиме создания; конференция — режим редактирования (см. design/mockups/calendar.html). */
conference?: ConferenceOut
onSaved: (conference: ConferenceOut) => void
onDeleted?: () => void
/** Только для режима редактирования — вернуться к пустой форме создания. */
onCancelEdit?: () => void
}
function defaultTimeSlot(): { date: string; time: string } {
const now = new Date()
now.setMinutes(0, 0, 0)
now.setHours(now.getHours() + 1)
return toLocalDateTimeParts(now.toISOString())
}
function weekdayOf(dateStr: string): number {
if (!dateStr) return 0
const [y, m, d] = dateStr.split('-').map(Number)
return (new Date(y, m - 1, d).getDay() + 6) % 7
}
/**
* Сайдбар-карточка создания/редактирования запланированной конференции —
* 1:1 `.create-card` из design/mockups/calendar.html. В отличие от статичного
* макета (раскрытие блоков через CSS `:has()`), состояние — управляемое
* (`useState`), как и предписано DESIGN_SYSTEM.md §4.13 для frontend.
*/
export function ConferenceFormCard({ conference, onSaved, onDeleted, onCancelEdit }: ConferenceFormCardProps) {
const isEdit = !!conference
const queryClient = useQueryClient()
const toast = useToast()
const { user: currentUser } = useAuth()
// Не-организатору форма недоступна вообще (кнопки правки/удаления скрыты
// везде выше по дереву), но проверяем и здесь как
// защиту в глубину: список конференции (`/conferences/my`) уже содержит
// `is_owner`, детальный запрос ниже не нужен для этой проверки.
const isOwner = conference?.is_owner ?? true
const initialSlot = useMemo(
() => (conference?.scheduled_at ? toLocalDateTimeParts(conference.scheduled_at) : defaultTimeSlot()),
[conference],
)
const [title, setTitle] = useState(conference?.title ?? '')
const [dateStr, setDateStr] = useState(initialSlot.date)
const [timeStr, setTimeStr] = useState(initialSlot.time)
const [durationMinutes, setDurationMinutes] = useState(conference?.duration_minutes ?? 30)
const [isClosed, setIsClosed] = useState(conference?.is_closed ?? false)
const [password, setPassword] = useState('')
const [summaryRecipients, setSummaryRecipients] = useState<'' | SummaryRecipientsMode>(
conference?.summary_recipients ?? '',
)
const [isPinned, setIsPinned] = useState(conference?.is_pinned ?? false)
const [recurrenceType, setRecurrenceType] = useState<RecurrenceType>(conference?.recurrence?.type ?? 'weekly')
const [weekdays, setWeekdays] = useState<number[]>(
conference?.recurrence?.weekdays ?? (conference ? [] : [weekdayOf(initialSlot.date)]),
)
const [dayOfMonth, setDayOfMonth] = useState(conference?.recurrence?.day_of_month ?? 1)
const [intervalDays, setIntervalDays] = useState(conference?.recurrence?.interval_days ?? 7)
const [formError, setFormError] = useState<string | null>(null)
const [confirmingDelete, setConfirmingDelete] = useState(false)
// Участники. Списковый `/conferences/my` не содержит
// `participants` (только детальный `GET /conferences/{id}`, owner|admin) —
// при редактировании подгружаем состав отдельным запросом.
const [participants, setParticipants] = useState<SelectedParticipant[]>([])
const [participantsTouched, setParticipantsTouched] = useState(false)
const participantsQuery = useQuery({
queryKey: ['conferences', 'detail', conference?.id],
queryFn: () => getConference(conference!.id),
enabled: isEdit,
})
// Сидируем состояние формы из загруженной детали конференции — паттерн
// «подстройка состояния при изменении пропса/данных запроса во время
// рендера» (react.dev/learn/you-might-not-need-an-effect), а не эффект:
// setState вызывается один раз для каждого нового объекта `participants`
// из query (`loadedParticipants` меняет identity только на новый ответ),
// не на каждый ре-рендер.
const loadedParticipants = participantsQuery.data?.participants
const [seededFrom, setSeededFrom] = useState<typeof loadedParticipants>(undefined)
if (isEdit && loadedParticipants && loadedParticipants !== seededFrom) {
setSeededFrom(loadedParticipants)
setParticipants(
loadedParticipants
.filter((p) => !p.is_organizer)
.map((p) => ({
key: p.user_id ?? p.email,
userId: p.user_id ?? undefined,
email: p.email,
displayName: p.name ?? p.email,
avatarUrl: p.avatar_url,
})),
)
}
const organizerChip = isEdit
? conference?.organizer_name
? { name: conference.organizer_name, avatarUrl: participantsQuery.data?.participants?.find((p) => p.is_organizer)?.avatar_url ?? null }
: null
: currentUser
? { name: currentUser.name_user, email: currentUser.email, avatarUrl: currentUser.avatar_url }
: null
function invalidateConferenceQueries() {
queryClient.invalidateQueries({ queryKey: ['conferences'] })
}
const saveMutation = useMutation({
mutationFn: (payload: ConferenceCreatePayload) =>
isEdit ? updateConference(conference!.id, payload) : createConference(payload),
onSuccess: (result) => {
invalidateConferenceQueries()
toast.show(isEdit ? 'Изменения сохранены' : 'Конференция запланирована', 'success')
onSaved(result)
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 422) {
setFormError('Проверьте правильность заполнения полей')
} else {
setFormError(isEdit ? 'Не удалось сохранить изменения. Попробуйте ещё раз' : 'Не удалось создать конференцию. Попробуйте ещё раз')
}
},
})
const unpinMutation = useMutation({
mutationFn: () => updateConference(conference!.id, { is_pinned: false }),
onSuccess: (result) => {
invalidateConferenceQueries()
toast.show('Конференция откреплена', 'success')
onSaved(result)
},
onError: () => toast.show('Не удалось открепить конференцию', 'error'),
})
const deleteMutation = useMutation({
mutationFn: () => deleteConference(conference!.id),
onSuccess: () => {
invalidateConferenceQueries()
toast.show('Конференция удалена', 'success')
onDeleted?.()
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
toast.show('Нельзя удалить — конференция сейчас идёт', 'error')
} else {
toast.show('Не удалось удалить конференцию', 'error')
}
setConfirmingDelete(false)
},
})
function toggleWeekday(day: number) {
setWeekdays((prev) => (prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day].sort((a, b) => a - b)))
}
const isRecurrenceValid =
!isPinned ||
(recurrenceType === 'weekly' && weekdays.length > 0) ||
(recurrenceType === 'biweekly' && weekdays.length > 0) ||
(recurrenceType === 'monthly' && dayOfMonth >= 1 && dayOfMonth <= 31) ||
(recurrenceType === 'every_n_days' && intervalDays >= 1)
const isPasswordValid = !isClosed || password.length >= 4
const canSubmit = !!dateStr && !!timeStr && durationMinutes > 0 && isPasswordValid && isRecurrenceValid && !saveMutation.isPending
const previewHint = useMemo(() => {
if (!isPinned) return ''
const dates = previewNextDates(
{ type: recurrenceType, weekdays, day_of_month: dayOfMonth, interval_days: intervalDays, anchor_date: dateStr || initialSlot.date },
2,
)
return formatPreviewHint(dates)
}, [isPinned, recurrenceType, weekdays, dayOfMonth, intervalDays, dateStr, initialSlot.date])
function handleSubmit() {
setFormError(null)
const recurrence: ConferenceRecurrence | undefined = isPinned
? {
type: recurrenceType,
weekdays: recurrenceType === 'weekly' || recurrenceType === 'biweekly' ? weekdays : undefined,
day_of_month: recurrenceType === 'monthly' ? dayOfMonth : undefined,
interval_days: recurrenceType === 'every_n_days' ? intervalDays : undefined,
anchor_date: dateStr,
time_local: timeStr,
timezone: browserTimeZone(),
duration_minutes: durationMinutes,
}
: undefined
// Состав участников отправляем только если его трогали — иначе поле не
// передаём вовсе (не отправляем `participants`), чтобы backend не
// заменил состав пустым списком (см. контракт `InviteeIn`). `p.email!` —
// безопасно: внешний участник (без `userId`) в UI добавляется только
// через `addExternal`, которая всегда заполняет `email` (ParticipantsPicker.tsx).
const participantsPayload: InviteeIn[] | undefined = participantsTouched
? participants.map((p) => (p.userId ? { user_id: p.userId } : { email: p.email! }))
: undefined
saveMutation.mutate({
title: title.trim() || undefined,
scheduled_at: localDateTimeToIso(dateStr, timeStr),
duration_minutes: durationMinutes,
is_pinned: isPinned,
recurrence,
is_closed: isClosed,
password: isClosed ? password : undefined,
summary_recipients: summaryRecipients === '' ? null : summaryRecipients,
participants: participantsPayload,
})
}
return (
<aside className="create-card">
<h2>
<CalendarIcon className="lucide" style={{ width: 20, height: 20 }} aria-hidden="true" />
{isEdit ? 'Редактирование конференции' : 'Новая конференция'}
</h2>
{isEdit && conference?.next_occurrence && (
<div className="next-occurrence">
<Clock style={{ width: 16, height: 16 }} aria-hidden="true" />
Следующее вхождение: {new Date(conference.next_occurrence).toLocaleString('ru-RU', {
weekday: 'long',
day: 'numeric',
month: 'long',
hour: '2-digit',
minute: '2-digit',
})}
</div>
)}
{formError && (
<div className="error-banner">
<AlertTriangle style={{ width: 18, height: 18 }} aria-hidden="true" />
<div>{formError}</div>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault()
handleSubmit()
}}
>
<div className="field">
<label htmlFor="conf-title">Название</label>
<input
id="conf-title"
type="text"
placeholder="Например, Синк по релизу 2.4"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={255}
/>
</div>
<div className="field-row">
<div className="field">
<label htmlFor="conf-date">Дата</label>
<input id="conf-date" type="date" value={dateStr} onChange={(e) => setDateStr(e.target.value)} />
</div>
<div className="field">
<label htmlFor="conf-time">Время</label>
<input id="conf-time" type="time" value={timeStr} onChange={(e) => setTimeStr(e.target.value)} />
</div>
</div>
<div className="field">
<label htmlFor="conf-duration">Длительность, мин</label>
<input
id="conf-duration"
type="number"
min={5}
step={5}
value={durationMinutes}
onChange={(e) => setDurationMinutes(Number(e.target.value) || 0)}
/>
</div>
<div className="field">
<label id="conf-summary-recipients-label" htmlFor="conf-summary-recipients">
Рассылка саммари
</label>
<Select
id="conf-summary-recipients"
aria-labelledby="conf-summary-recipients-label"
value={summaryRecipients}
onChange={(v) => setSummaryRecipients(v as '' | SummaryRecipientsMode)}
options={SUMMARY_RECIPIENTS_OPTIONS}
/>
</div>
<div className="field">
<label id="conf-participants-label">Участники</label>
<ParticipantsPicker
participants={participants}
onChange={(next) => {
setParticipants(next)
setParticipantsTouched(true)
}}
organizer={organizerChip}
disabled={isEdit && participantsQuery.isPending}
/>
{isEdit && participantsQuery.isPending && <p className="field-hint">Загрузка состава участников</p>}
</div>
<div className="toggle-block">
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
<div className="toggle-copy">
<strong>Закрытая по паролю</strong>
<span>Вход только по паролю, включая гостей</span>
</div>
<label className="switch">
<input type="checkbox" checked={isClosed} onChange={(e) => setIsClosed(e.target.checked)} />
<span className="slider" />
</label>
</div>
{isClosed && (
<div className="password-block field">
<label htmlFor="conf-password">
<Lock style={{ width: 14, height: 14 }} aria-hidden="true" />
Пароль конференции
</label>
<input
id="conf-password"
type="password"
placeholder="Минимум 4 символа"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
)}
</div>
<div className="pin-block">
<div className="toggle-row">
<div className="toggle-copy">
<strong>{isEdit ? 'Закреплена' : 'Закрепить постоянную конференцию'}</strong>
<span>Сохранится и попадёт в «Мои конференции», не исчезнет после встречи</span>
</div>
<label className="switch">
<input type="checkbox" checked={isPinned} onChange={(e) => setIsPinned(e.target.checked)} />
<span className="slider" />
</label>
</div>
{isPinned && (
<div className="recurrence-panel">
<label className="recurrence-option-row">
<input type="radio" name="recurrence-type" checked={recurrenceType === 'weekly'} onChange={() => setRecurrenceType('weekly')} />
По дням недели, еженедельно
</label>
{recurrenceType === 'weekly' && (
<div className="recurrence-sub">
<WeekdayToggles selected={weekdays} onToggle={toggleWeekday} />
{previewHint && <p className="recurrence-hint">{previewHint}</p>}
</div>
)}
<label className="recurrence-option-row">
<input type="radio" name="recurrence-type" checked={recurrenceType === 'biweekly'} onChange={() => setRecurrenceType('biweekly')} />
Раз в 2 недели
</label>
{recurrenceType === 'biweekly' && (
<div className="recurrence-sub">
<WeekdayToggles selected={weekdays} onToggle={toggleWeekday} />
{previewHint && <p className="recurrence-hint">{previewHint}</p>}
</div>
)}
<label className="recurrence-option-row">
<input type="radio" name="recurrence-type" checked={recurrenceType === 'monthly'} onChange={() => setRecurrenceType('monthly')} />
Раз в месяц
</label>
{recurrenceType === 'monthly' && (
<div className="recurrence-sub">
<div className="field" style={{ marginBottom: 0 }}>
<label htmlFor="conf-day-of-month">Число месяца</label>
<input
id="conf-day-of-month"
type="number"
min={1}
max={31}
value={dayOfMonth}
onChange={(e) => setDayOfMonth(Number(e.target.value) || 1)}
/>
</div>
<p className="recurrence-hint">
Если в месяце нет такого числа переносится на последний день месяца (напр. 28/29 февраля)
</p>
</div>
)}
<label className="recurrence-option-row">
<input
type="radio"
name="recurrence-type"
checked={recurrenceType === 'every_n_days'}
onChange={() => setRecurrenceType('every_n_days')}
/>
Каждые N дней
</label>
{recurrenceType === 'every_n_days' && (
<div className="recurrence-sub">
<div className="field" style={{ marginBottom: 0 }}>
<label htmlFor="conf-interval-days">N (дней)</label>
<input
id="conf-interval-days"
type="number"
min={1}
value={intervalDays}
onChange={(e) => setIntervalDays(Number(e.target.value) || 1)}
/>
</div>
{previewHint && <p className="recurrence-hint">{previewHint}</p>}
</div>
)}
</div>
)}
</div>
{!isOwner ? (
<p className="field-hint" style={{ marginTop: 'var(--space-3)' }}>
Редактирование доступно только организатору конференции.
</p>
) : isEdit && conference?.is_pinned ? (
<div className="btn-row">
<button type="submit" className="btn btn-primary" disabled={!canSubmit}>
{saveMutation.isPending ? 'Сохраняем…' : 'Сохранить изменения'}
</button>
<button type="button" className="btn btn-secondary" onClick={() => unpinMutation.mutate()} disabled={unpinMutation.isPending}>
Открепить
</button>
</div>
) : (
<button type="submit" className="btn btn-primary full" disabled={!canSubmit}>
{saveMutation.isPending ? 'Сохраняем…' : isEdit ? 'Сохранить изменения' : 'Создать конференцию'}
</button>
)}
</form>
{isEdit && (
<>
{isOwner && (
<div className="danger-link-row">
{confirmingDelete ? (
<div className="error-banner" style={{ alignItems: 'center', marginTop: 'var(--space-4)' }}>
<AlertTriangle style={{ width: 18, height: 18 }} aria-hidden="true" />
<div style={{ flex: 1, textAlign: 'left' }}>Удалить конференцию навсегда? Действие необратимо.</div>
<button
type="button"
className="btn btn-danger"
style={{ width: 'auto', padding: '6px 14px' }}
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
<Check style={{ width: 14, height: 14 }} aria-hidden="true" />
Да, удалить
</button>
</div>
) : (
<button type="button" className="btn-text" onClick={() => setConfirmingDelete(true)}>
Удалить конференцию навсегда
</button>
)}
</div>
)}
<div className="danger-link-row">
<button type="button" className="btn-text" style={{ color: 'var(--color-ink-500)' }} onClick={onCancelEdit}>
Отмена редактирования
</button>
</div>
</>
)}
</aside>
)
}
function WeekdayToggles({ selected, onToggle }: { selected: number[]; onToggle: (day: number) => void }) {
return (
<div className="weekday-toggles">
{WEEKDAY_SHORT.map((label, day) => (
<button
type="button"
key={label}
className={`weekday-chip${selected.includes(day) ? ' is-selected' : ''}`}
onClick={() => onToggle(day)}
aria-pressed={selected.includes(day)}
>
{label}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,73 @@
import { Link, useNavigate } from 'react-router-dom'
import { Calendar, Lock, Play, Repeat, X } from 'lucide-react'
import type { OccurrenceOut } from '@/api/conferences'
interface ConferenceOccurrenceDialogProps {
occurrence: OccurrenceOut
onClose: () => void
}
function formatRange(startsAt: string, endsAt: string): string {
const start = new Date(startsAt)
const end = new Date(endsAt)
const dateLabel = start.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
const startTime = start.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
const endTime = end.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
return `${dateLabel}, ${startTime}${endTime}`
}
/**
* Быстрый просмотр вхождения конференции по клику на чип в календаре — для
* конференций, которые пользователь не организует (не владелец —
* `is_owner`): свои конференции CalendarPage открывает сразу в форме
* редактирования (`ConferenceFormCard`). Здесь — минимум: когда, статус, вход
* и ссылка на управление; подробности состава участников/организатора — в
* ховер-карточке при наведении/фокусе на чип (`ConferenceHoverCard`).
*/
export function ConferenceOccurrenceDialog({ occurrence, onClose }: ConferenceOccurrenceDialogProps) {
const navigate = useNavigate()
return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="occurrence-dialog-title" onClick={onClose}>
<div className="modal-panel" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2 id="occurrence-dialog-title">
{occurrence.is_closed && <Lock className="title-lock" aria-hidden="true" />}
{occurrence.title ?? 'Конференция без названия'}
</h2>
<button type="button" className="modal-close" aria-label="Закрыть" onClick={onClose}>
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
<div className="detail-row">
<Calendar style={{ width: 18, height: 18 }} aria-hidden="true" />
<span className="label">Когда</span>
<span>{formatRange(occurrence.starts_at, occurrence.ends_at)}</span>
</div>
<div className="detail-row">
<Repeat style={{ width: 18, height: 18 }} aria-hidden="true" />
<span className="label">Номер</span>
<span>{occurrence.number}</span>
</div>
{occurrence.is_pinned && (
<div className="detail-row">
<Repeat style={{ width: 18, height: 18 }} aria-hidden="true" />
<span className="label">Статус</span>
<span>Закреплённая конференция повторяется по расписанию</span>
</div>
)}
<div className="modal-actions">
<button type="button" className="btn btn-primary" onClick={() => navigate(`/j/${occurrence.slug}`)}>
<Play style={{ width: 16, height: 16 }} aria-hidden="true" />
Войти
</button>
</div>
<p className="field-hint" style={{ textAlign: 'center', marginTop: 'var(--space-3)' }}>
Управление и редактирование в <Link to="/my-conferences">«Моих конференциях»</Link>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,184 @@
import { useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Search, UserPlus, X } from 'lucide-react'
import { searchUsers } from '@/api/users'
import { Avatar } from '@/components/ui/Avatar'
/** Простая проверка формата email — не заменяет валидацию backend, только клиентская подсказка. */
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/**
* Выбранный участник конференции — зарегистрированный (`userId` заполнен,
* `email` для него не всегда известен: `GET /users?q=` не возвращает email,
* только `{id, display_name, avatar_url}`) или внешний гость (только `email`,
* `userId` не заполнен).
*/
export interface SelectedParticipant {
/** Стабильный идентификатор чипа — `userId` для зарегистрированных, email для внешних. */
key: string
userId?: string
email?: string
/** Что показывать в чипе/списке — имя для зарегистрированных, email для внешних. */
displayName: string
avatarUrl?: string | null
}
interface OrganizerChip {
name: string
email?: string
avatarUrl?: string | null
}
interface ParticipantsPickerProps {
participants: SelectedParticipant[]
onChange: (next: SelectedParticipant[]) => void
/** Организатор — показывается первым чипом без крестика удаления (не входит в редактируемый список). */
organizer?: OrganizerChip | null
disabled?: boolean
}
/**
* Пикер участников конференции — новый UI-элемент, для
* которого нет готового макета в `design/mockups/`; собран из уже
* утверждённых паттернов дизайн-системы: чипы как у фильтров «Моих
* конференций» (`.chip`), выпадающий список результатов — паттерн дроплиста
* селекта (§4.16, `.vc-select-panel`/`.vc-select-option`), поле поиска — как
* `.search-wrap` админки. Поиск зарегистрированных — debounce 300 мс; если
* введённая строка похожа на email — параллельно предлагается добавить её
* как внешнего участника (backend не отдаёт email по поиску, поэтому нельзя
* достоверно исключить совпадение с уже найденным зарегистрированным).
*/
export function ParticipantsPicker({ participants, onChange, organizer, disabled }: ParticipantsPickerProps) {
const [inputValue, setInputValue] = useState('')
const [debounced, setDebounced] = useState('')
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const timer = setTimeout(() => setDebounced(inputValue.trim()), 300)
return () => clearTimeout(timer)
}, [inputValue])
const { data: results, isFetching } = useQuery({
queryKey: ['users', 'search', debounced],
queryFn: () => searchUsers(debounced, 8),
enabled: debounced.length >= 2,
})
useEffect(() => {
if (!open) return
function handlePointerDown(event: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [open])
const selectedUserIds = new Set(participants.map((p) => p.userId).filter(Boolean))
const selectedEmails = new Set(participants.map((p) => p.email?.toLowerCase()).filter(Boolean))
const candidates = (results ?? []).filter((u) => !selectedUserIds.has(u.id))
const trimmed = inputValue.trim()
const isEmailLike = EMAIL_RE.test(trimmed)
const canAddExternal = isEmailLike && !selectedEmails.has(trimmed.toLowerCase())
function addRegistered(user: { id: string; display_name: string; avatar_url: string | null }) {
onChange([...participants, { key: user.id, userId: user.id, displayName: user.display_name, avatarUrl: user.avatar_url }])
setInputValue('')
setDebounced('')
setOpen(false)
}
function addExternal(email: string) {
onChange([...participants, { key: email.toLowerCase(), email, displayName: email }])
setInputValue('')
setDebounced('')
setOpen(false)
}
function removeParticipant(key: string) {
onChange(participants.filter((p) => p.key !== key))
}
const showPanel = open && debounced.length >= 2 && (candidates.length > 0 || canAddExternal || isFetching)
return (
<div className="participants-picker" ref={rootRef}>
{(organizer || participants.length > 0) && (
<div className="participants-chips">
{organizer && (
<span className="participant-chip is-organizer">
<Avatar name={organizer.name} avatarUrl={organizer.avatarUrl} size={20} />
{organizer.name}
<span className="participant-chip-role">организатор</span>
</span>
)}
{participants.map((p) => (
<span className="participant-chip" key={p.key}>
<Avatar name={p.displayName} avatarUrl={p.avatarUrl} size={20} />
{p.displayName}
<button
type="button"
className="participant-chip-remove"
aria-label={`Убрать участника: ${p.displayName}`}
onClick={() => removeParticipant(p.key)}
disabled={disabled}
>
<X style={{ width: 12, height: 12 }} aria-hidden="true" />
</button>
</span>
))}
</div>
)}
<div className="participants-search-wrap">
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
<input
type="text"
placeholder="Имя, email или адрес внешнего гостя…"
value={inputValue}
disabled={disabled}
onChange={(e) => {
setInputValue(e.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
if (candidates.length === 1) addRegistered(candidates[0])
else if (canAddExternal) addExternal(trimmed)
} else if (e.key === 'Escape') {
setOpen(false)
}
}}
/>
</div>
{showPanel && (
<ul className="vc-select-panel participants-results">
{isFetching && candidates.length === 0 && <li className="participants-results-hint">Ищем</li>}
{candidates.map((user) => (
<li key={user.id} className="vc-select-option" onClick={() => addRegistered(user)}>
<span className="participant-option-body">
<Avatar name={user.display_name} avatarUrl={user.avatar_url} size={22} />
{user.display_name}
</span>
</li>
))}
{canAddExternal && (
<li className="vc-select-option" onClick={() => addExternal(trimmed)}>
<span className="participant-option-body">
<UserPlus style={{ width: 16, height: 16 }} aria-hidden="true" />
Добавить «{trimmed}» как внешнего участника
</span>
</li>
)}
{!isFetching && candidates.length === 0 && !canAddExternal && (
<li className="participants-results-hint">Никого не нашли введите email внешнего участника полностью</li>
)}
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,117 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { CalendarDays, LayoutDashboard, LogOut, Menu, Users, Video } from 'lucide-react'
import { useAuth } from '@/auth/useAuth'
import { Avatar } from '@/components/ui/Avatar'
import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle'
const NAV_LINKS = [
{ to: '/lobby', label: 'Лобби', icon: Video },
{ to: '/calendar', label: 'Календарь', icon: CalendarDays },
{ to: '/my-conferences', label: 'Мои конференции', icon: Users },
]
/** Доступен только администраторам — сама админка тоже защищена route-guard'ом (см. App.tsx). */
const ADMIN_NAV_LINK = { to: '/admin', label: 'Админка', icon: LayoutDashboard }
/**
* Верхняя навигация светлой оболочки (лобби/календарь/мои конференции/админка),
* см. design/mockups/lobby.html.
*
* Пункты навигации убраны в скрывающуюся панель за бургером (`.menu-wrap` +
* `.burger` + `.menu`) — как в макете; список ссылок там расширен нашими
* реальными разделами (в самом макете в панели — заглушки «Скачать
* приложение»/«О сервисе», которых как отдельных экранов нет). Бургер виден
* всегда, а не только на мобильной ширине — макет не прячет его по media
* query ни на одной ширине, это единственный способ навигации между
* разделами оболочки.
*
* Пилюля с ФИО теперь ссылка на страницу профиля
* (`/profile`) — в макете это была декоративная заглушка без перехода;
* аватар — общий компонент `Avatar` (фото или инициалы).
*/
export function ShellTopbar() {
const { user, logout } = useAuth()
const location = useLocation()
const navigate = useNavigate()
const [menuOpen, setMenuOpen] = useState(false)
const menuWrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!menuOpen) return
function handlePointerDown(event: MouseEvent) {
if (menuWrapRef.current && !menuWrapRef.current.contains(event.target as Node)) {
setMenuOpen(false)
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') setMenuOpen(false)
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('keydown', handleKeydown)
}
}, [menuOpen])
async function handleLogout() {
setMenuOpen(false)
await logout()
navigate('/login', { replace: true })
}
return (
<header className="topbar">
<Link to="/lobby" className="brand-mark">
<LogoMark /> VidConf
</Link>
<div className="topbar-right">
<ThemeToggle />
<Link to="/profile" className="user-pill">
<Avatar name={user?.name_user ?? '?'} avatarUrl={user?.avatar_url} />
<span className="user-pill-name">{user?.name_user ?? '—'}</span>
</Link>
<div className="menu-wrap" ref={menuWrapRef}>
<button
type="button"
className="burger"
aria-expanded={menuOpen}
aria-controls="main-menu"
aria-label={menuOpen ? 'Закрыть меню' : 'Открыть меню'}
onClick={() => setMenuOpen((v) => !v)}
>
<Menu className="icon" aria-hidden="true" />
</button>
<nav className={`menu${menuOpen ? ' is-open' : ''}`} id="main-menu" aria-label="Основное меню">
{[...NAV_LINKS, ...(user?.role === 'admin' ? [ADMIN_NAV_LINK] : [])].map((link) => {
const Icon = link.icon
return (
<Link
key={link.to}
to={link.to}
className={location.pathname.startsWith(link.to) ? 'is-active' : ''}
onClick={() => setMenuOpen(false)}
>
<Icon className="icon" aria-hidden="true" />
{link.label}
</Link>
)
})}
<div className="menu-divider" />
<button type="button" className="danger" onClick={handleLogout}>
<LogOut className="icon" aria-hidden="true" />
Выйти
</button>
</nav>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,120 @@
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import { AlertCircle, Send, X } from 'lucide-react'
import type { ChatConnectionStatus, ChatMessageOut } from '@/hooks/useChat'
import { formatLocalTime } from '@/lib/localTime'
interface ChatPanelProps {
messages: ChatMessageOut[]
status: ChatConnectionStatus
statusMessage: string | null
onSend: (text: string) => void
onClose: () => void
}
/**
* Панель чата комнаты конференции (см. design/mockups/room.html, блок
* `.chat-panel`). Всегда в развёрнутом виде рендерится только пока сама
* панель открыта — сворачивание/разворачивание и счётчик непрочитанных
* управляются на уровне RoomPage (WS-соединение живёт независимо от того,
* открыта ли панель, — иначе при сворачивании терялась бы история).
*
* Textarea, а не `<input>` из макета — сознательное отступление ради
* Enter/Shift+Enter (перенос строки), стили сохранены визуально идентичными
* пилюле-полю из макета.
*/
export function ChatPanel({ messages, status, statusMessage, onSend, onClose }: ChatPanelProps) {
const [draft, setDraft] = useState('')
const listRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Автоскролл к последнему сообщению — только если пользователь и так был
// внизу списка, чтобы не мешать чтению прокрученной вверх истории.
useEffect(() => {
const el = listRef.current
if (!el) return
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
if (distanceFromBottom < 120) {
el.scrollTop = el.scrollHeight
}
}, [messages])
function handleSend() {
const text = draft.trim()
if (!text || status !== 'open') return
onSend(text)
setDraft('')
if (textareaRef.current) textareaRef.current.style.height = 'auto'
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSend()
}
}
function handleDraftChange(event: ChangeEvent<HTMLTextAreaElement>) {
setDraft(event.target.value)
// Авторасширение textarea до 4 строк, дальше — внутренний скролл.
const el = event.target
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
}
return (
<aside className="chat-panel">
<div className="chat-head">
<h2>Чат встречи</h2>
<button type="button" aria-label="Свернуть чат" onClick={onClose}>
<X className="lucide" aria-hidden="true" />
</button>
</div>
{statusMessage && (
<p className="chat-status-banner">
<AlertCircle className="lucide" aria-hidden="true" /> {statusMessage}
</p>
)}
<div className="chat-messages" ref={listRef}>
{messages.length === 0 && status === 'open' && !statusMessage && (
<p className="chat-empty">Сообщений пока нет начните обсуждение</p>
)}
{messages.map((message) => (
<div className="msg" key={message.id}>
<div className="msg-meta">
<span className="msg-author">
{message.author_name}
{message.is_guest && <span className="msg-guest-badge">гость</span>}
</span>
<span className="msg-time">{formatLocalTime(message.created_at)}</span>
</div>
<div className="msg-bubble">{message.text}</div>
</div>
))}
</div>
<form
className="chat-input-row"
onSubmit={(event) => {
event.preventDefault()
handleSend()
}}
>
<textarea
ref={textareaRef}
rows={1}
placeholder="Написать сообщение…"
value={draft}
onChange={handleDraftChange}
onKeyDown={handleKeyDown}
disabled={status !== 'open'}
maxLength={2000}
/>
<button type="submit" aria-label="Отправить" disabled={!draft.trim() || status !== 'open'}>
<Send className="lucide" aria-hidden="true" />
</button>
</form>
</aside>
)
}

View File

@@ -0,0 +1,110 @@
import { X } from 'lucide-react'
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
interface DeviceSettingsDialogProps {
onClose: () => void
}
/** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */
function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): string {
return device.label || `${fallback} ${index + 1}`
}
/**
* Диалог «Настройки устройств» — два селекта
* на хуках `@livekit/components-react`: список устройств и переключение —
* целиком в `useMediaDeviceSelect` (сама подписана на
* `RoomEvent.MediaDevicesChanged`), персист выбора — в `usePersistentUserChoices`
* (localStorage, читается заново при следующем входе в комнату — см.
* `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
*
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
* он создал бы отдельный, ни с чем не связанный `Room()` и переключал бы
* устройство «в никуда».
*/
export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
const toast = useToast()
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
const camera = useMediaDeviceSelect({ kind: 'videoinput' })
async function handleMicChange(deviceId: string) {
try {
await mic.setActiveMediaDevice(deviceId)
saveAudioInputDeviceId(deviceId)
} catch {
// activeDeviceId хука — источник истины, состояние селекта само не меняется.
toast.show('Не удалось переключить микрофон — устройство занято или отключено', 'error')
}
}
async function handleCameraChange(deviceId: string) {
try {
await camera.setActiveMediaDevice(deviceId)
saveVideoInputDeviceId(deviceId)
} catch {
toast.show('Не удалось переключить камеру — устройство занято или отключено', 'error')
}
}
return (
<div
className="room-modal-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="device-settings-title"
onClick={onClose}
>
<div className="room-modal-panel" onClick={(e) => e.stopPropagation()}>
<div className="room-modal-head">
<h2 id="device-settings-title">Настройки устройств</h2>
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
<div className="room-field">
<label htmlFor="device-settings-mic">Микрофон</label>
<select
id="device-settings-mic"
value={mic.activeDeviceId}
onChange={(e) => void handleMicChange(e.target.value)}
>
{/* Заглушка на случай, пока activeDeviceId не совпадает ни с одним
устройством из списка (нет разрешения на медиа/список ещё не
перечислен) — без неё controlled-select рассинхронизируется с
DOM (ни одна из настоящих option не соответствует value). */}
<option value="" disabled>
Определяется
</option>
{mic.devices.map((device, index) => (
<option key={device.deviceId} value={device.deviceId}>
{deviceLabel(device, index, 'Микрофон')}
</option>
))}
</select>
</div>
<div className="room-field">
<label htmlFor="device-settings-camera">Камера</label>
<select
id="device-settings-camera"
value={camera.activeDeviceId}
onChange={(e) => void handleCameraChange(e.target.value)}
>
<option value="" disabled>
Определяется
</option>
{camera.devices.map((device, index) => (
<option key={device.deviceId} value={device.deviceId}>
{deviceLabel(device, index, 'Камера')}
</option>
))}
</select>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,164 @@
import { ScreenShare } from 'lucide-react'
import { Track } from 'livekit-client'
import {
AudioTrack,
ConnectionQualityIndicator,
FocusToggle,
LockLockedIcon,
ParticipantName,
ParticipantTile,
ScreenShareIcon,
TrackMutedIndicator,
VideoTrack,
isTrackReference,
useEnsureTrackRef,
useFeatureContext,
useIsEncrypted,
useParticipantInfo,
type ParticipantClickEvent,
type TrackReferenceOrPlaceholder,
} from '@livekit/components-react'
import { Avatar } from '@/components/ui/Avatar'
/** Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata` на backend) — JSON `{"avatar_url": "..."}`; у гостей отсутствуют. */
interface ParticipantMetadata {
avatar_url?: string | null
}
/** Разбирает `participant.metadata` в URL аватара — `null`, если поля нет, метаданные пусты или невалидны (гость). */
function parseAvatarUrl(metadata: string | undefined): string | null {
if (!metadata) return null
try {
const parsed = JSON.parse(metadata) as ParticipantMetadata
return typeof parsed.avatar_url === 'string' && parsed.avatar_url ? parsed.avatar_url : null
} catch {
return null
}
}
/**
* Содержимое плитки — рендерится ВНУТРИ `ParticipantTile` (значит, внутри её
* `TrackRefContext`/`ParticipantContext`), поэтому берёт трек и участника из
* контекста теми же хуками, что использует сама библиотека в оригинальной
* разметке (см. `node_modules/@livekit/components-react/src/components/participant/ParticipantTile.tsx`,
* версия 2.9.23 — источник этой копии).
*/
function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
const trackReference = useEnsureTrackRef()
const isEncrypted = useIsEncrypted(trackReference.participant)
const autoManageSubscription = useFeatureContext()?.autoSubscription
// useParticipantInfo — реактивные name/metadata участника (переподписка на
// ParticipantMetadataChanged); в текущей версии metadata приходит статично
// с токена при входе и в течение сеанса не меняется, но хук — штатный
// способ читать её реактивно, если это когда-нибудь изменится.
const { name, metadata } = useParticipantInfo({ participant: trackReference.participant })
const avatarUrl = parseAvatarUrl(metadata)
const displayName = name || trackReference.participant.identity
// Чип «Вы демонстрируете экран» — только на СВОЕЙ демонстрации;
// `onStopSharing` передаётся снаружи (`RoomStage.tsx`) только для фокус-
// плитки, поэтому в карусели/гриде чип не появится даже при том же треке.
const showSharingChip = Boolean(
onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal,
)
return (
<>
{isTrackReference(trackReference) &&
(trackReference.publication?.kind === 'video' ||
trackReference.source === Track.Source.Camera ||
trackReference.source === Track.Source.ScreenShare) ? (
<VideoTrack trackRef={trackReference} manageSubscription={autoManageSubscription} />
) : (
isTrackReference(trackReference) && <AudioTrack trackRef={trackReference} />
)}
{/* Вместо штатной иконки-заглушки (ParticipantPlaceholder) — аватар
участника: фото по avatar_url из метаданных, фолбэк — инициалы имени
(см. `ui/Avatar`). Видимость (opacity) этого блока при выключенной
камере управляется тем же CSS-правилом библиотеки
(`[data-lk-video-muted='true'][data-lk-source='camera'] .lk-participant-placeholder`)
— data-атрибуты на корневой div выставляет сам `ParticipantTile`
независимо от children. */}
<div className="lk-participant-placeholder">
<Avatar name={displayName} avatarUrl={avatarUrl} className="room-tile-avatar" />
</div>
<div className="lk-participant-metadata">
<div className="lk-participant-metadata-item">
{trackReference.source === Track.Source.Camera ? (
<>
{isEncrypted && <LockLockedIcon style={{ marginRight: '0.25rem' }} />}
<TrackMutedIndicator
trackRef={{ participant: trackReference.participant, source: Track.Source.Microphone }}
show="muted"
/>
<ParticipantName />
</>
) : (
<>
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
{/* `ParticipantName` рендерит `participant.name` (или identity),
а `children` — необязательный суффикс ПОСЛЕ имени; оригинал
дописывал сюда английское `'s screen` («Имя's screen»),
здесь — русский суффикс («Имя — демонстрация экрана»). */}
<ParticipantName>{' — демонстрация экрана'}</ParticipantName>
</>
)}
</div>
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
</div>
<FocusToggle trackRef={trackReference} />
{showSharingChip && (
<div className="stage-sharing-chip">
<ScreenShare className="lucide" aria-hidden="true" />
<span>Вы демонстрируете экран</span>
<button type="button" onClick={onStopSharing}>
Остановить
</button>
</div>
)}
</>
)
}
interface RoomParticipantTileProps {
trackRef?: TrackReferenceOrPlaceholder
disableSpeakingIndicator?: boolean
onParticipantClick?: (event: ParticipantClickEvent) => void
/**
* Остановить демонстрацию экрана — если передано, при рендере СВОЕЙ активной
* демонстрации (Track.Source.ScreenShare + `participant.isLocal`) поверх
* плитки показывается чип «Вы демонстрируете экран» с кнопкой «Остановить»
* Передавать только для фокус-плитки — в карусели демонстрация
* в этом приложении не появляется (см. `RoomStage.tsx`).
*/
onStopSharing?: () => void
}
/**
* Обёртка над штатным `ParticipantTile` (@livekit/components-react 2.9.23,
* версия запинена в package.json — при апгрейде библиотеки сверить разметку
* заново): та же структура (видео/аудио-трек, блок метаданных с именем и индикатором мьюта,
* FocusToggle), но вместо стандартной SVG-пиктограммы при выключенной камере —
* аватар участника. Работает и для локального участника (свои метаданные из
* собственного токена), и для удалённых.
*
* Пин-логика оригинала (`handleSubscribe`/сброс пина при отписке от трека)
* сознательно опущена — приложение пока нигде не создаёт `LayoutContext`
* (пиннинг плиток не реализован), поэтому в оригинале эта ветка и так была
* мёртвым кодом без провайдера контекста.
*/
export function RoomParticipantTile({
trackRef,
disableSpeakingIndicator,
onParticipantClick,
onStopSharing,
}: RoomParticipantTileProps) {
return (
<ParticipantTile
trackRef={trackRef}
disableSpeakingIndicator={disableSpeakingIndicator}
onParticipantClick={onParticipantClick}
>
<TileBody onStopSharing={onStopSharing} />
</ParticipantTile>
)
}

View File

@@ -0,0 +1,228 @@
import { useState } from 'react'
import { Track } from 'livekit-client'
import {
CarouselLayout,
FocusLayoutContainer,
GridLayout,
RoomAudioRenderer,
isTrackReference,
useRoomContext,
useSpeakingParticipants,
useTracks,
type TrackReferenceOrPlaceholder,
} from '@livekit/components-react'
import { RoomParticipantTile } from '@/components/room/RoomParticipantTile'
import { pickStageFocus } from '@/components/room/stageFocus'
/**
* Стабильная (модульная, не пересоздаётся на каждый рендер) ссылка на
* источники треков для `useTracks`. Важно: внутренний `useMemo` хука
* `useTracks` держит СЫРОЙ параметр `sources` в списке зависимостей (не
* только `trackReferences`/`participants`), поэтому инлайновый литерал
* массива в вызове (`useTracks([{ source: ... }], ...)`) пересоздавался бы
* заново при каждом рендере `RoomStage` и ломал мемоизацию: `tracks` был бы
* НОВОЙ ссылкой на каждый рендер даже без реальных изменений участников.
* Раньше это было безобидно (код ниже не сравнивал `tracks` по ссылке
* между рендерами), но стало критично после того, как ниже появилось
* состояние, которое обновляется именно по признаку «пришёл новый `tracks`»
* (см. комментарий у `prevTracks`) — без этой константы получался бесконечный
* цикл рендеров.
*
* Демонстрация экрана: к камере добавлен `Track.Source.ScreenShare`
* без плейсхолдера (демонстрация либо есть, либо участника просто нет в этом
* наборе — в отличие от камеры, «пустая» демонстрация не показывается вовсе).
* Штатный паттерн смешанных источников — как в `VideoConference` из самой
* `@livekit/components-react`.
*/
const STAGE_TRACK_SOURCES = [
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
]
/** Ключ трека для `pickStageFocus` — см. обоснование в `stageFocus.ts`. */
function stageTrackKey(t: TrackReferenceOrPlaceholder): string {
return `${t.participant.identity}:${t.source}`
}
/**
* Основная сцена конференции: превью остальных участников + крупная плитка
* активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
* участниках, GridLayout при одном/двух).
*
* Раскладка — вертикальная колонка миниатюр слева от основной сцены (не
* горизонтальная лента, см. design/mockups/room.html после правки: узкая
* колонка сбоку, скролл по вертикали). Это штатное поведение самого
* `@livekit/components-react` (2.9.23) — `FocusLayoutContainer` рендерит
* `.lk-focus-layout` (`grid-template-columns: 1fr 5fr` — первая, узкая
* колонка ровно под превью), а `CarouselLayout` без явного `orientation`
* сам определяет направление по РАЗМЕРАМ СВОЕГО КОНТЕЙНЕРА (`height >= width`
* → `vertical`) — важно НЕ передавать сюда className, перекрывающий базовый
* `lk-carousel` (см. её исходник: `{...props}` после `className="lk-carousel"`
* в JSX полностью заменяет, а не объединяет класс — раньше здесь было
* `className="preview-row"`, из-за этого терялись все стили направления/
* авторазмера тайлов, и лента визуально превращалась в горизонтальный ряд).
* Адаптив уже встроен в @livekit/components-styles: на ≤600px
* `.lk-focus-layout` сам переключается в колонку (спикер сверху, превью —
* узкая горизонтальная полоса снизу, `.lk-carousel{order:1}`) — без
* дополнительного JS с нашей стороны. Точечные доводки размеров/скроллбара —
* styles/room.css (`.stage-tiles`/`.lk-carousel`).
*
* Демонстрация экрана: при наличии хотя бы одной активной
* демонстрации `FocusLayoutContainer` форсируется БЕЗУСЛОВНО (независимо от
* числа участников) — в фокусе демонстрация, в карусели ВСЕ camera-треки,
* включая камеру самого демонстратора (решение архитектора). Без демонстрации
* поведение не меняется — прежняя раскладка по спикеру/первому треку.
*
* Проп `variant="pip"` — для рендера
* ВНУТРИ мини-плеера (Document PiP, портал в `RoomPage.tsx`). В этом режиме
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида
* — и фокус ЖИВО следует за активным спикером (см. `followSpeaker` у
* `pickStageFocus`), а не удерживается, как в основном окне. Основной рендер
* (`variant="full"`, дефолт) не меняется вовсе.
*/
export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
const room = useRoomContext()
const tracks = useTracks(STAGE_TRACK_SOURCES, {
onlySubscribed: false,
})
// Только для PiP (см. followSpeaker ниже) — активные спикеры уже
// отсортированы SDK по громкости (`Room.activeSpeakers`, обновляются по
// `RoomEvent.ActiveSpeakersChanged`, событие шлётся лишь при РЕАЛЬНОЙ смене
// состава/порядка говорящих — не дребезжит на каждый чих, в отличие от
// сырого `participant.isSpeaking`). Хук вызывается безусловно (Rules of
// Hooks) — для `variant="full"` его результат просто не используется.
const speakingParticipants = useSpeakingParticipants()
const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera)
const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare)
const hasScreenShare = screenShareTracks.length > 0
// Кто «в фокусе» — храним по составному ключу (см. `stageTrackKey` и
// обоснование в `stageFocus.ts`) в состоянии, а не пересчитываем заново из
// порядка `tracks` на каждом рендере. Порядок этого массива не
// гарантированно стабилен между кадрами (у @livekit/components-core он
// может поменяться при переходе placeholder → реальный трек, при
// подключении нового участника или при старте/остановке демонстрации), и
// если брать `tracks[0]`/`.find()` напрямую при каждом рендере, «в фокусе»
// и «в карусели» на паре соседних рендеров может оказаться то один, то
// другой участник — тогда CarouselLayout получает на входе список, где
// полностью сменился единственный элемент (был трек A, стал трек B), а её
// собственный `useVisualStableUpdate` считает это не reflow'ом, а
// поломанным состоянием и кидает (пойманную и залогированную, но всё
// равно шумную) `Error: Element not part of the array`.
//
// Вся логика выбора «что дальше в фокусе» вынесена в чистую функцию
// `pickStageFocus` (last-wins для нового шэра, удержание текущего фокуса,
// фолбэк на активного спикера/первый трек) — здесь только вызов в
// санкционированном React-паттерне «adjusting state during rendering» (не
// useEffect — eslint-plugin-react-hooks не даёт ни читать ref во время
// рендера, ни синхронно звать setState внутри эффекта), под охраной
// сравнения с предыдущим `tracks` — сравниваем по ссылке, `useTracks` отдаёт
// новый массив только когда реально что-то изменилось (см. комментарий у
// `STAGE_TRACK_SOURCES` про стабильность ссылки).
//
// Для PiP (`variant="pip"`) пересчёт триггерится ЕЩЁ и сменой
// `speakingParticipants` (тоже сравнение по ссылке — хук отдаёт новый
// массив только при реальном изменении состава/порядка говорящих), и
// передаётся `followSpeaker: true` — фокус живо переключается на нового
// спикера, а не удерживает прежний (см. правило 2 в `pickStageFocus`). Для
// основного окна (`variant="full"`) `speakingChanged` всегда `false` —
// поведение байт-в-байт то же, что было до этой правки.
const [prevTracks, setPrevTracks] = useState(tracks)
const [prevSpeakingParticipants, setPrevSpeakingParticipants] = useState(speakingParticipants)
const [focusKey, setFocusKey] = useState<string | null>(null)
const tracksChanged = tracks !== prevTracks
const speakingChanged = variant === 'pip' && speakingParticipants !== prevSpeakingParticipants
if (tracksChanged || speakingChanged) {
const prevKeys = prevTracks.map(stageTrackKey)
if (tracksChanged) setPrevTracks(tracks)
if (speakingChanged) setPrevSpeakingParticipants(speakingParticipants)
// Источник «говорящих» — РАЗНЫЙ для основного окна и PiP, намеренно:
// здесь строго тот же расчёт, что был в основном окне ДО этой правки
// (`participant.isSpeaking`, без сортировки — фолбэк только на первый
// рендер, дребезг неважен, см. JSDoc правила 3/4 в stageFocus.ts), а для
// PiP — упорядоченный по громкости `speakingParticipants` (нужен именно
// порядок, чтобы взять самого громкого, и именно throttled-источник SDK,
// чтобы followSpeaker не дёргался на каждый чих).
const speakingCameraKeys =
variant === 'pip'
? speakingParticipants
.map((p) => cameraTracks.find((t) => t.participant.identity === p.identity))
.filter((t): t is TrackReferenceOrPlaceholder => Boolean(t))
.map(stageTrackKey)
: cameraTracks.filter((t) => t.participant.isSpeaking).map(stageTrackKey)
const result = pickStageFocus({
cameraKeys: cameraTracks.map(stageTrackKey),
screenShareKeys: screenShareTracks.map(stageTrackKey),
speakingCameraKeys,
prevKeys,
prevFocusKey: focusKey,
followSpeaker: variant === 'pip',
// Только для PiP — в основном окне фолбэк на «первый трек» не менялся
// (см. JSDoc про speakingChanged выше: поведение full-варианта не трогаем).
localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null,
})
if (result.focusKey !== focusKey) {
setFocusKey(result.focusKey)
}
}
const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0]
const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null
// При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И
// ВСЕ демонстрации, проигравшие фокус (второй демонстратор при last-wins —
// см. `pickStageFocus`): её трек продолжает публиковаться, и, по решению
// архитектора, должен остаться видимой обычной плиткой в карусели, а не
// молча пропадать из UI. Сравнение — по тому же ключу `identity:source`,
// что и у `pickStageFocus` (устойчивее прямого сравнения ссылок между
// рендерами). Когда демонстратор один — его screenshare как раз и есть
// focusTrack, фильтр исключает его из карусели (дубля нет).
const carouselTracks = hasScreenShare
? [...cameraTracks, ...screenShareTracks.filter((t) => stageTrackKey(t) !== focusTrackKey)]
: tracks.filter((t) => t !== focusTrack)
/**
* Остановить СВОЮ демонстрацию — передаётся в фокус-плитку, чип «Вы
* демонстрируете экран» и его видимость решает сама `RoomParticipantTile`
* (показывает только когда трек фокуса — своя демонстрация экрана).
*/
function handleStopSharing() {
void room.localParticipant.setScreenShareEnabled(false)
}
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
// грида, одна плитка на весь контейнер (см. `.room-single-tile`,
// `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus`
// (с `followSpeaker: true` для этого варианта) — переиспользуем как есть.
if (variant === 'pip') {
return (
<section className="stage room-single-tile">
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
<RoomAudioRenderer />
</section>
)
}
return (
<section className="stage">
{!hasScreenShare && (!focusTrack || carouselTracks.length === 0) ? (
<GridLayout tracks={tracks} className="stage-tiles">
<RoomParticipantTile />
</GridLayout>
) : (
<FocusLayoutContainer className="stage-tiles">
<CarouselLayout tracks={carouselTracks}>
<RoomParticipantTile />
</CarouselLayout>
{/* FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile
(см. её исходник), поэтому вместо неё используем свою обёртку
напрямую с тем же trackRef (аватар в фокус-плитке). */}
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
</FocusLayoutContainer>
)}
<RoomAudioRenderer />
</section>
)
}

View File

@@ -0,0 +1,215 @@
import {
LogOut,
Maximize,
MessageSquare,
Mic,
MicOff,
Minimize,
PictureInPicture2,
ScreenShare,
ScreenShareOff,
Settings,
Video,
VideoOff,
} from 'lucide-react'
import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
import { DisconnectButton, useTrackToggle } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
/**
* Опции захвата демонстрации экрана: `audio: true` — звук
* вкладки/экрана там, где браузер его отдаёт (Chrome/Edge — вкладка почти
* всегда, целый экран — только Windows); `selfBrowserSurface: 'exclude'`
* — не предлагать в списке
* источников собственную вкладку (зеркальный туннель самой конференции);
* `surfaceSwitching: 'include'` — разрешить переключать источник прямо во
* время демонстрации, не останавливая её; `systemAudio: 'include'` — не
* запрещать захват системного звука при выборе «весь экран». Вынесено в
* модульную константу — `useTrackToggle` держит `JSON.stringify(captureOptions)`
* в зависимостях внутреннего `useMemo`, инлайновый литерал был бы безвреден,
* но константа явнее фиксирует неизменность опций.
*/
const SCREEN_SHARE_CAPTURE_OPTIONS: ScreenShareCaptureOptions = {
audio: true,
selfBrowserSurface: 'exclude',
surfaceSwitching: 'include',
systemAudio: 'include',
}
interface RoomToolbarProps {
/** Показывать ли кнопку чата — `JoinOut.chat_enabled` И чат не помечен недоступным (close-код 4404). */
chatVisible: boolean
chatOpen: boolean
/** Число непрочитанных сообщений, накопленных со времени последнего открытия панели. */
chatUnreadCount: number
onToggleChat: () => void
/** Открыть диалог настроек устройств. */
onToggleSettings: () => void
/** `document.fullscreenEnabled` — false скрывает кнопку. */
fullscreenSupported: boolean
fullscreenActive: boolean
onToggleFullscreen: () => void
/** Доступен ли мини-плеер хоть в каком-то виде (Document PiP либо video-фолбэк) — false в Firefox скрывает кнопку. */
pipSupported: boolean
pipActive: boolean
onTogglePiP: () => void
}
/**
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/
* настройки устройств/полноэкранный режим/мини-плеер/чат/выход — собственные
* кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и панели чата,
* стилизованные по design/mockups/room.html.
*/
export function RoomToolbar({
chatVisible,
chatOpen,
chatUnreadCount,
onToggleChat,
onToggleSettings,
fullscreenSupported,
fullscreenActive,
onToggleFullscreen,
pipSupported,
pipActive,
onTogglePiP,
}: RoomToolbarProps) {
const toast = useToast()
const mic = useTrackToggle({ source: Track.Source.Microphone })
const camera = useTrackToggle({ source: Track.Source.Camera })
const screenShare = useTrackToggle({
source: Track.Source.ScreenShare,
captureOptions: SCREEN_SHARE_CAPTURE_OPTIONS,
// Отмена браузерного диалога выбора экрана/окна (пользователь нажал
// «Отмена») приходит сюда как NotAllowedError — молча игнорируем, это не
// ошибка приложения. Прочие сбои (например, `NotReadableError` при
// занятом источнике) показываем тостом.
onDeviceError: (error) => {
if (error.name === 'NotAllowedError') return
toast.show('Не удалось начать демонстрацию экрана', 'error')
},
})
return (
<footer className="room-toolbar">
<button
type="button"
{...mic.buttonProps}
className={`tb-btn${mic.enabled ? '' : ' is-off'}`}
aria-label={mic.enabled ? 'Выключить микрофон' : 'Включить микрофон'}
>
<span className="icon-shell">
{mic.enabled ? (
<Mic className="lucide" aria-hidden="true" />
) : (
<MicOff className="lucide" aria-hidden="true" />
)}
</span>
<span className="label">Микрофон</span>
</button>
<button
type="button"
{...camera.buttonProps}
className={`tb-btn${camera.enabled ? '' : ' is-off'}`}
aria-label={camera.enabled ? 'Выключить камеру' : 'Включить камеру'}
>
<span className="icon-shell">
{camera.enabled ? (
<Video className="lucide" aria-hidden="true" />
) : (
<VideoOff className="lucide" aria-hidden="true" />
)}
</span>
<span className="label">Камера</span>
</button>
<button
type="button"
{...screenShare.buttonProps}
className={`tb-btn${screenShare.enabled ? ' is-sharing' : ''}`}
aria-label={screenShare.enabled ? 'Остановить демонстрацию экрана' : 'Демонстрировать экран'}
>
<span className="icon-shell">
{screenShare.enabled ? (
<ScreenShareOff className="lucide" aria-hidden="true" />
) : (
<ScreenShare className="lucide" aria-hidden="true" />
)}
</span>
<span className="label">Демонстрация</span>
</button>
<button
type="button"
className="tb-btn"
aria-label="Настройки устройств"
onClick={onToggleSettings}
>
<span className="icon-shell">
<Settings className="lucide" aria-hidden="true" />
</span>
<span className="label">Устройства</span>
</button>
{fullscreenSupported && (
<button
type="button"
className={`tb-btn${fullscreenActive ? ' is-panel-open' : ''}`}
aria-label={fullscreenActive ? 'Выйти из полноэкранного режима' : 'Развернуть на весь экран'}
aria-pressed={fullscreenActive}
onClick={onToggleFullscreen}
>
<span className="icon-shell">
{fullscreenActive ? (
<Minimize className="lucide" aria-hidden="true" />
) : (
<Maximize className="lucide" aria-hidden="true" />
)}
</span>
<span className="label">Экран</span>
</button>
)}
{pipSupported && (
<button
type="button"
className={`tb-btn${pipActive ? ' is-panel-open' : ''}`}
aria-label={pipActive ? 'Свернуть мини-окно' : 'Открыть мини-окно'}
aria-pressed={pipActive}
onClick={onTogglePiP}
>
<span className="icon-shell">
<PictureInPicture2 className="lucide" aria-hidden="true" />
</span>
<span className="label">Мини-окно</span>
</button>
)}
{chatVisible && (
<button
type="button"
className={`tb-btn${chatOpen ? ' is-panel-open' : ''}`}
aria-label={chatOpen ? 'Свернуть чат' : 'Открыть чат'}
aria-pressed={chatOpen}
onClick={onToggleChat}
>
<span className="icon-shell">
<MessageSquare className="lucide" aria-hidden="true" />
{chatUnreadCount > 0 && (
<span className="badge-count">{chatUnreadCount > 9 ? '9+' : chatUnreadCount}</span>
)}
</span>
<span className="label">Чат</span>
</button>
)}
<DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции">
<span className="icon-shell">
<LogOut className="lucide" aria-hidden="true" />
</span>
<span className="label">Выйти</span>
</DisconnectButton>
</footer>
)
}

View File

@@ -0,0 +1,62 @@
import { Check, Copy, Users } from 'lucide-react'
import { useParticipants } from '@livekit/components-react'
import { useState } from 'react'
import { pluralizeParticipants } from '@/lib/pluralize'
interface RoomTopbarProps {
roomName: string
/** Slug/номер конференции из адреса — для инвайт-чипа (копирование ссылки). */
slug?: string
number?: string
}
/**
* Верхняя панель комнаты: название, число участников и инвайт-чип
* (номер + копирование ссылки-приглашения `/j/:slug`).
*
* Примечание: в design/mockups/room.html этого чипа нет — он добавлен,
* чтобы ссылка и номер конференции были видны и копировались, в стиле
* тёмных токенов темы `room` (см. `--color-room-tile*`), без новых
* цветов и форм.
*/
export function RoomTopbar({ roomName, slug, number }: RoomTopbarProps) {
const participants = useParticipants()
const [copied, setCopied] = useState(false)
async function handleCopy() {
if (!slug) return
const link = `${window.location.origin}/j/${slug}`
try {
await navigator.clipboard.writeText(link)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Буфер обмена недоступен (нет разрешения/не https) — молча игнорируем,
// это некритичный вспомогательный элемент интерфейса.
}
}
return (
<header className="room-topbar">
<div className="room-title-block">
<h1>{roomName}</h1>
<p>
<span className="rec-dot" aria-hidden="true" />
Конференция активна · <Users className="lucide" style={{ width: 14, height: 14 }} aria-hidden="true" />{' '}
{participants.length} {pluralizeParticipants(participants.length)}
</p>
</div>
{slug && (
<button type="button" className="room-invite-chip" onClick={handleCopy}>
<span>{number ? `${number}` : slug}</span>
{copied ? (
<Check style={{ width: 14, height: 14 }} aria-hidden="true" />
) : (
<Copy style={{ width: 14, height: 14 }} aria-hidden="true" />
)}
{copied ? 'Скопировано' : 'Пригласить'}
</button>
)}
</header>
)
}

View File

@@ -0,0 +1,133 @@
/**
* Чистая функция выбора «сцены в фокусе» (демонстрация экрана).
*
* Никаких зависимостей от React/DOM/LiveKit SDK — на вход только примитивы,
* на выход тоже примитивы; можно покрыть unit-тестом при появлении раннера
* (vitest в проект сознательно не вводим, тестируем вручную).
*
* Ключ трека — НЕ sid публикации и НЕ голая identity участника, а составной
* `${identity}:${source}` (см. `RoomStage.tsx`, функция `stageTrackKey`):
* - голой identity недостаточно — у демонстратора одновременно живут camera-
* и screenshare-трек, и по одной identity нельзя понять, какой из них в
* фокусе;
* - чистый sid публикации нестабилен для камеры: каждое вкл/выкл камеры
* (`setCameraEnabled`) — это unpublish/publish с НОВЫМ sid, а при
* выключенной камере используется placeholder без sid вовсе (см. исходный
* комментарий в RoomStage.tsx про гонку placeholder → реальный трек). Если
* бы ключом был sid, сфокусированный участник терял бы фокус на каждом
* цикле мьюта камеры.
* `identity:source` даёт и устойчивость (не меняется при мьюте камеры), и
* однозначность (camera и screen_share одного участника — разные ключи).
*/
/** Вид источника трека на сцене. */
export type StageFocusKind = 'camera' | 'screen_share'
export interface PickStageFocusInput {
/** Ключи текущих камера-треков (один на участника — трек либо его плейсхолдер). */
cameraKeys: readonly string[]
/** Ключи текущих screenshare-треков (один на активную демонстрацию). */
screenShareKeys: readonly string[]
/**
* Подмножество `cameraKeys` — говорящие сейчас участники, УПОРЯДОЧЕННОЕ по
* громкости (первый — самый громкий; см. `Room.activeSpeakers`, который
* сортирует именно так) — для фолбэка на активного спикера и, при
* `followSpeaker`, для живого переключения фокуса.
*/
speakingCameraKeys: readonly string[]
/** Объединённый набор ключей (camera+screenshare) с ПРЕДЫДУЩЕГО рендера — определяет, какие screenshare-ключи «новые». */
prevKeys: readonly string[]
/** Ключ, что был в фокусе на предыдущем рендере; `null` — фокус ещё не выбирался. */
prevFocusKey: string | null
/**
* Режим мини-плеера (PiP): фокус должен ЖИВО следовать за
* активным спикером (переключаться сразу, а не удерживать текущий), в
* отличие от основного окна сцены — там держим фокус, даже если заговорил
* кто-то другой (см. правило 2 ниже и обоснование в `RoomStage.tsx` про
* дребезг `isSpeaking` у фейковых медиапотоков). По умолчанию `false` —
* поведение основного окна не меняется.
*/
followSpeaker?: boolean
/**
* Ключ локального участника (та же схема `identity:source`) — предпоследний
* фолбэк, ПЕРЕД чисто первым элементом набора: если фокуса ещё не было и
* никто не говорит, лучше показать «себя», чем произвольного первого
* участника (актуально для свежего открытия PiP). Не указан — фолбэк не
* меняется (последний, по порядку `cameraKeys`).
*/
localKey?: string | null
}
export interface PickStageFocusResult {
/** Ключ трека в фокусе; `null` — треков нет вовсе. */
focusKey: string | null
kind: StageFocusKind | null
}
/**
* Выбирает, какой трек показать крупно (в `FocusLayoutContainer`).
*
* Правила (PiP показывает только активное окно):
* 1. Last-wins: если с прошлого рендера появился НОВЫЙ screenshare-ключ —
* фокус безусловно переходит на него (последний из новых, если появилось
* сразу несколько), даже если до этого в фокусе была камера или другая
* демонстрация. Так же ведут себя типовые UI конференций (Google Meet).
* 2. `followSpeaker` (только PiP): если сейчас есть говорящий — фокус СРАЗУ
* переходит на него, даже если текущий фокус ещё жив. В основном окне
* (`followSpeaker: false`) этот шаг пропускается — см. правило 3.
* 3. Иначе, если текущий фокус жив (остался среди camera/screenshare-ключей) —
* держим его: НЕ дёргаем фокус на каждый ре-рендер (изменение состава
* участников, дребезг isSpeaking и т.п.). Это и есть «стабильный фолбэк»
* для PiP, когда никто не говорит — держим предыдущего активного.
* 4. Иначе (фокуса не было или он пропал) — приоритет активной демонстрации
* над камерой; среди камер — активный спикер, иначе `localKey` (если
* указан и жив), иначе первая по порядку.
*/
export function pickStageFocus({
cameraKeys,
screenShareKeys,
speakingCameraKeys,
prevKeys,
prevFocusKey,
followSpeaker = false,
localKey = null,
}: PickStageFocusInput): PickStageFocusResult {
if (cameraKeys.length === 0 && screenShareKeys.length === 0) {
return { focusKey: null, kind: null }
}
const prevKeySet = new Set(prevKeys)
const newScreenShareKeys = screenShareKeys.filter((key) => !prevKeySet.has(key))
if (newScreenShareKeys.length > 0) {
return { focusKey: newScreenShareKeys[newScreenShareKeys.length - 1], kind: 'screen_share' }
}
if (followSpeaker) {
const liveSpeaker = speakingCameraKeys.find((key) => cameraKeys.includes(key))
if (liveSpeaker) {
return { focusKey: liveSpeaker, kind: 'camera' }
}
}
if (prevFocusKey && screenShareKeys.includes(prevFocusKey)) {
return { focusKey: prevFocusKey, kind: 'screen_share' }
}
if (prevFocusKey && cameraKeys.includes(prevFocusKey)) {
return { focusKey: prevFocusKey, kind: 'camera' }
}
if (screenShareKeys.length > 0) {
return { focusKey: screenShareKeys[screenShareKeys.length - 1], kind: 'screen_share' }
}
const speaking = speakingCameraKeys.find((key) => cameraKeys.includes(key))
if (speaking) {
return { focusKey: speaking, kind: 'camera' }
}
if (localKey && cameraKeys.includes(localKey)) {
return { focusKey: localKey, kind: 'camera' }
}
return { focusKey: cameraKeys[0] ?? null, kind: cameraKeys[0] ? 'camera' : null }
}

View File

@@ -0,0 +1,31 @@
/**
* Аватар пользователя — переиспользуемый `.avatar` (DESIGN_SYSTEM.md §1.5):
* фото, если загружено (`avatarUrl`), иначе заглушка с инициалами имени.
* Используется в топбаре, таблице «Пользователи» админки, пикере участников
* конференции и ховер-карточке.
*/
interface AvatarProps {
name: string
avatarUrl?: string | null
/** Размер в пикселях — по умолчанию 28px, как базовый `.avatar` в shell.css. */
size?: number
className?: string
}
function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return '??'
return parts
.slice(0, 2)
.map((p) => p[0]?.toUpperCase())
.join('')
}
export function Avatar({ name, avatarUrl, size, className }: AvatarProps) {
const style = size ? { width: size, height: size } : undefined
return (
<span className={`avatar${className ? ` ${className}` : ''}`} style={style} aria-hidden="true">
{avatarUrl ? <img src={avatarUrl} alt="" /> : initialsOf(name)}
</span>
)
}

View File

@@ -0,0 +1,103 @@
import { useMemo, type CSSProperties } from 'react'
import { createPortal } from 'react-dom'
import { useQuery } from '@tanstack/react-query'
import { Repeat, Users as UsersIcon } from 'lucide-react'
import { getConference } from '@/api/conferences'
import { Avatar } from '@/components/ui/Avatar'
interface ConferenceHoverCardProps {
/** `null` — карточка скрыта. */
conferenceId: string | null
/** Элемент, от которого позиционируется поповер (чип календаря, карточка «Моих конференций»). */
anchorEl: HTMLElement | null
}
const CARD_WIDTH = 300
/** Позиция карточки — чистая функция от уже смонтированного `anchorEl` (без DOM-эффекта: `getBoundingClientRect` доступен и во время рендера). */
function computeStyle(anchorEl: HTMLElement): CSSProperties {
const rect = anchorEl.getBoundingClientRect()
const left = Math.min(rect.left, window.innerWidth - CARD_WIDTH - 12)
const spaceBelow = window.innerHeight - rect.bottom
const openUpwards = spaceBelow < 220
return {
position: 'fixed',
left: Math.max(12, left),
top: openUpwards ? undefined : rect.bottom + 8,
bottom: openUpwards ? window.innerHeight - rect.top + 8 : undefined,
width: CARD_WIDTH,
}
}
/**
* Ховер-карточка конференции — новый элемент без
* готового макета: собрана из утверждённых паттернов (карточка `--shadow-lg`
* как у поповеров/модалок §6, бейдж закреплённости — иконка `Repeat` как в
* §4.6). Данные — ленивый `GET /conferences/{id}` (доступен только owner|admin
* — для конференций без прав на просмотр карточка тихо не показывается,
* см. `isError`), закэшированы react-query по `conferenceId`.
*
* Рендерится через портал в `document.body` с `position: fixed`, чтобы не
* обрезаться `overflow` ячеек календаря/грида карточек. `pointer-events: none`
* на самой карточке — не должна перехватывать клики по чипу/карточке под ней.
*/
export function ConferenceHoverCard({ conferenceId, anchorEl }: ConferenceHoverCardProps) {
const { data, isLoading, isError } = useQuery({
queryKey: ['conferences', 'detail', conferenceId],
queryFn: () => getConference(conferenceId!),
enabled: !!conferenceId,
staleTime: 60_000,
retry: false,
})
// getBoundingClientRect читает уже отрисованный DOM (anchorEl смонтирован
// родителем раньше) — не побочный эффект рендера, а чтение геометрии
// существующего узла, поэтому вычисляем прямо в теле рендера (useMemo).
const style = useMemo(() => (anchorEl ? computeStyle(anchorEl) : null), [anchorEl])
if (!conferenceId || !anchorEl || !style || isError) return null
return createPortal(
<div className="conf-hover-card" style={style} role="tooltip" aria-live="polite">
{isLoading || !data ? (
<p className="conf-hover-card-hint">Загрузка</p>
) : (
<>
<p className="conf-hover-card-title">{data.title ?? 'Конференция без названия'}</p>
<p className="conf-hover-card-row">
<span className="conf-hover-card-label">Организатор</span>
{data.organizer_name ?? '—'}
</p>
<p className="conf-hover-card-row">
{data.is_pinned ? (
<>
<Repeat style={{ width: 13, height: 13 }} aria-hidden="true" />
Закреплённая (постоянная)
</>
) : (
'Разовая'
)}
</p>
{data.participants && data.participants.length > 0 && (
<div className="conf-hover-card-participants">
<span className="conf-hover-card-label">
<UsersIcon style={{ width: 13, height: 13 }} aria-hidden="true" />
Участники ({data.participants.length})
</span>
<ul>
{data.participants.map((p) => (
<li key={p.user_id ?? p.email}>
<Avatar name={p.name ?? p.email} avatarUrl={p.avatar_url} size={18} />
{p.name ?? p.email}
{p.is_organizer && <span className="conf-hover-card-tag">организатор</span>}
</li>
))}
</ul>
</div>
)}
</>
)}
</div>,
document.body,
)
}

View File

@@ -0,0 +1,55 @@
import { useState } from 'react'
import { Check, Copy } from 'lucide-react'
interface CopyPillProps {
label: string
/** Отображаемое (может быть обрезано `text-overflow: ellipsis`) значение. */
value: string
/** Что именно копируется в буфер обмена (может отличаться от отображаемого значения). */
copyText: string
}
/**
* Пилюля «ссылка/номер» с копированием (DESIGN_SYSTEM.md §4.15, «Мои
* конференции»). Статус «скопировано» передаётся сменой формы иконки
* (Copy → Check) и текстовым тултипом — не только цветом.
*/
export function CopyPill({ label, value, copyText }: CopyPillProps) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
try {
await navigator.clipboard.writeText(copyText)
} catch {
// Буфер обмена недоступен (нет разрешения/небезопасный контекст) — молча игнорируем.
}
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<div className="pill-group">
<span className="pill-label">{label}</span>
<div className="code-pill">
<span className="pill-value">{value}</span>
<button
type="button"
className="copy-btn"
onClick={handleCopy}
aria-label={copied ? 'Скопировано' : `Скопировать: ${label.toLowerCase()}`}
>
{copied ? (
<Check className="icon-check" style={{ width: 14, height: 14 }} aria-hidden="true" />
) : (
<Copy className="icon-copy" style={{ width: 14, height: 14 }} aria-hidden="true" />
)}
{copied && (
<span className="copy-toast" aria-hidden="true">
Скопировано
</span>
)}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,67 @@
/**
* Логотип VidConf — знак «объектив» (design/DESIGN_SYSTEM.md §1.4, design/logo.svg).
* Монохромный, наследует цвет текста (currentColor) — обычно `--color-ink-700`.
*
* Два варианта:
* - `LogoMark` — упрощённая версия для мелких размеров (топбар рядом со
* словом «VidConf», 22×22px), 1:1 повторяет `.brand-mark .logo-mark` из
* всех макетов оболочки (lobby.html, calendar.html, admin.html, join.html).
* - `LogoMedallion` — полная версия с 4 концентрическими кольцами (design/logo.svg),
* используется в медальоне-центре мозаики лобби (`.hub-logo`, 56×56px внутри
* круга 156×156px, см. design/mockups/lobby.html).
*/
/** Упрощённый знак объектива для топбара (viewBox 32×32). */
export function LogoMark({ className }: { className?: string }) {
return (
<svg className={className ?? 'logo-mark'} viewBox="0 0 32 32" fill="none" aria-hidden="true">
<polygon points="21,16 18.5,20.3 13.5,20.3 11,16 13.5,11.7 18.5,11.7" fill="currentColor" />
<circle cx="16" cy="16" r="13" stroke="currentColor" strokeWidth="2.6" strokeDasharray="12 6" />
</svg>
)
}
/** Полный знак объектива (4 кольца) для медальона в центре мозаики лобби. */
export function LogoMedallion({ className }: { className?: string }) {
return (
<svg className={className ?? 'logo-mark'} viewBox="0 0 64 64" fill="none" aria-hidden="true">
<polygon points="41,32 36.5,39.8 27.5,39.8 23,32 27.5,24.2 36.5,24.2" fill="currentColor" />
<circle
cx="32"
cy="32"
r="15"
stroke="currentColor"
strokeWidth="3"
strokeDasharray="16 7 28 9 12 16"
strokeDashoffset="5"
/>
<circle
cx="32"
cy="32"
r="20"
stroke="currentColor"
strokeWidth="2"
strokeDasharray="22 10 8 14 34 12 6 10"
strokeDashoffset="40"
/>
<circle
cx="32"
cy="32"
r="25"
stroke="currentColor"
strokeWidth="3.5"
strokeDasharray="40 14 12 10 26 16 20 8"
strokeDashoffset="80"
/>
<circle
cx="32"
cy="32"
r="29"
stroke="currentColor"
strokeWidth="1.6"
strokeDasharray="10 6 60 20 30 10 24 8"
strokeDashoffset="120"
/>
</svg>
)
}

View File

@@ -0,0 +1,233 @@
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { AlertCircle, Check, ChevronDown } from 'lucide-react'
export interface SelectOption {
value: string
label: string
disabled?: boolean
}
interface SelectProps {
value: string
onChange: (value: string) => void
options: SelectOption[]
placeholder?: string
disabled?: boolean
/** Показать ошибку (2px `--color-danger`, иконка `AlertCircle`) — текст ошибки рисует вызывающий код под полем. */
error?: boolean
id?: string
'aria-label'?: string
'aria-labelledby'?: string
}
/**
* Кастомный селект (DESIGN_SYSTEM.md §4.16) — заменяет нативный `<select>`.
* Паттерн listbox-combobox: триггер — геометрия инпута (§4.4), дроплист —
* паттерн `.menu` (design/mockups/lobby.html), пункт — `.menu a`. Полностью
* на var()-токенах — обе темы работают без отдельных правил (см. §4.16
* «Обе темы»).
*
* Клавиатура: Enter/Space/стрелки открывают список (фокус остаётся на
* триггере), стрелки двигают клавиатурную подсветку (не по кругу),
* Home/End — к границам, ввод буквы — type-ahead, Enter/Space на подсвеченном
* пункте — выбор и закрытие, Esc/клик вне/Tab — закрытие без изменений.
*/
export function Select({
value,
onChange,
options,
placeholder = 'Выберите…',
disabled = false,
error = false,
id,
...aria
}: SelectProps) {
const generatedId = useId()
const baseId = id ?? generatedId
const listboxId = `${baseId}-listbox`
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const optionRefs = useRef<Map<string, HTMLLIElement>>(new Map())
const typeaheadRef = useRef<{ text: string; timer: ReturnType<typeof window.setTimeout> | undefined }>({
text: '',
timer: undefined,
})
const enabledOptions = useMemo(() => options.filter((o) => !o.disabled), [options])
const selectedOption = options.find((o) => o.value === value) ?? null
// `null` — единственный сентинел «нет активного пункта»; пустая строка
// ('' — например, «По умолчанию» в SUMMARY_RECIPIENTS_OPTIONS) — легитимное
// значение и НЕ должна схлопываться в `null` через `||`-фоллбэки (был баг:
// ArrowDown ставил activeValue='', а `if (activeValue)`/`activeValue ? …`
// ниже трактовали это как «ничего не подсвечено», из-за чего Enter не
// выбирал пункт и aria-activedescendant не выставлялся). Поэтому везде ниже
// сравнение только с `null` (`!== null`/`=== null`), никогда `||`/truthiness.
const [open, setOpen] = useState(false)
const [activeValue, setActiveValue] = useState<string | null>(
enabledOptions.some((o) => o.value === value) ? value : null,
)
function closePanel() {
setOpen(false)
}
function openPanel() {
if (disabled) return
const current = enabledOptions.find((o) => o.value === value)
setActiveValue(current ? current.value : (enabledOptions[0]?.value ?? null))
setOpen(true)
}
// Закрытие по клику вне триггера/панели — тот же приём, что бургер-меню ShellTopbar.
useEffect(() => {
if (!open) return
function handlePointerDown(event: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) closePanel()
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [open])
useEffect(() => {
if (open && activeValue !== null) {
optionRefs.current.get(activeValue)?.scrollIntoView({ block: 'nearest' })
}
}, [open, activeValue])
function moveActive(direction: 1 | -1) {
if (enabledOptions.length === 0) return
const currentIndex = enabledOptions.findIndex((o) => o.value === activeValue)
const nextIndex =
currentIndex === -1 ? 0 : Math.min(Math.max(currentIndex + direction, 0), enabledOptions.length - 1)
setActiveValue(enabledOptions[nextIndex].value)
}
function commitActive() {
if (activeValue !== null) onChange(activeValue)
closePanel()
triggerRef.current?.focus()
}
function selectOption(option: SelectOption) {
if (option.disabled) return
onChange(option.value)
closePanel()
triggerRef.current?.focus()
}
function handleTypeahead(char: string) {
const buf = typeaheadRef.current
window.clearTimeout(buf.timer)
buf.text += char.toLowerCase()
buf.timer = window.setTimeout(() => {
buf.text = ''
}, 600)
const currentIndex = enabledOptions.findIndex((o) => o.value === activeValue)
// Ищем от следующего после подсвеченного, по кругу — как в нативном select.
const ordered = [...enabledOptions.slice(currentIndex + 1), ...enabledOptions.slice(0, currentIndex + 1)]
const match = ordered.find((o) => o.label.toLowerCase().startsWith(buf.text))
if (match) setActiveValue(match.value)
}
function handleTriggerKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (disabled) return
if (!open) {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
openPanel()
}
return
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
moveActive(1)
break
case 'ArrowUp':
event.preventDefault()
moveActive(-1)
break
case 'Home':
event.preventDefault()
if (enabledOptions[0]) setActiveValue(enabledOptions[0].value)
break
case 'End':
event.preventDefault()
if (enabledOptions.length > 0) setActiveValue(enabledOptions[enabledOptions.length - 1].value)
break
case 'Enter':
case ' ':
event.preventDefault()
commitActive()
break
case 'Escape':
event.preventDefault()
closePanel()
break
case 'Tab':
closePanel()
break
default:
if (event.key.length === 1) handleTypeahead(event.key)
}
}
return (
<div className="vc-select" ref={rootRef}>
<button
type="button"
ref={triggerRef}
id={baseId}
className={`vc-select-trigger${error ? ' has-error' : ''}`}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
aria-activedescendant={open && activeValue !== null ? `${listboxId}-opt-${activeValue}` : undefined}
aria-disabled={disabled || undefined}
disabled={disabled}
onClick={() => (open ? closePanel() : openPanel())}
onKeyDown={handleTriggerKeyDown}
{...aria}
>
{error && <AlertCircle className="vc-select-error-icon" style={{ width: 16, height: 16 }} aria-hidden="true" />}
<span className={selectedOption ? 'vc-select-value' : 'vc-select-placeholder'}>
{selectedOption ? selectedOption.label : placeholder}
</span>
<ChevronDown className={`vc-select-chevron${open ? ' is-open' : ''}`} style={{ width: 18, height: 18 }} aria-hidden="true" />
</button>
{open && (
<ul className="vc-select-panel" role="listbox" id={listboxId}>
{options.map((option) => {
const isSelected = option.value === value
const isActive = option.value === activeValue
return (
<li
key={option.value}
id={`${listboxId}-opt-${option.value}`}
ref={(el) => {
if (el) optionRefs.current.set(option.value, el)
else optionRefs.current.delete(option.value)
}}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled || undefined}
className={`vc-select-option${isActive ? ' is-active' : ''}${isSelected ? ' is-selected' : ''}${option.disabled ? ' is-disabled' : ''}`}
onMouseEnter={() => !option.disabled && setActiveValue(option.value)}
onClick={() => selectOption(option)}
>
<span>{option.label}</span>
{isSelected && <Check style={{ width: 16, height: 16 }} aria-hidden="true" />}
</li>
)
})}
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from '@/hooks/useTheme'
/**
* Переключатель темы оболочки «солнце/луна» (см. design/mockups/dark/*.html,
* `.theme-toggle`). Комнату конференции не затрагивает — она всегда на своей
* теме `[data-theme="room"]` (см. RoomPage.tsx), не участвующей в переключении.
*/
export function ThemeToggle({ className = '' }: { className?: string }) {
const { resolved, setTheme } = useTheme()
return (
<div className={`theme-toggle${className ? ` ${className}` : ''}`} role="group" aria-label="Переключить тему оформления">
<button
type="button"
className={`theme-toggle-btn${resolved === 'light' ? ' is-active' : ''}`}
aria-pressed={resolved === 'light'}
aria-label="Светлая тема"
onClick={() => setTheme('light')}
>
<Sun className="icon" aria-hidden="true" />
</button>
<button
type="button"
className={`theme-toggle-btn${resolved === 'dark' ? ' is-active' : ''}`}
aria-pressed={resolved === 'dark'}
aria-label="Тёмная тема"
onClick={() => setTheme('dark')}
>
<Moon className="icon" aria-hidden="true" />
</button>
</div>
)
}

View File

@@ -0,0 +1,63 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
import { AlertCircle, CheckCircle2, Info } from 'lucide-react'
type ToastVariant = 'error' | 'success' | 'info'
interface ToastItem {
id: number
message: string
variant: ToastVariant
}
interface ToastContextValue {
show: (message: string, variant?: ToastVariant) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
const ICON_BY_VARIANT: Record<ToastVariant, typeof AlertCircle> = {
error: AlertCircle,
success: CheckCircle2,
info: Info,
}
let nextId = 1
/** Лёгкий провайдер тостов (без внешней библиотеки) по токенам дизайн-системы. */
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const show = useCallback((message: string, variant: ToastVariant = 'info') => {
const id = nextId++
setToasts((prev) => [...prev, { id, message, variant }])
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, 4000)
}, [])
const value = useMemo(() => ({ show }), [show])
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-stack" role="status" aria-live="polite">
{toasts.map((toast) => {
const Icon = ICON_BY_VARIANT[toast.variant]
return (
<div key={toast.id} className={`toast toast-${toast.variant}`}>
<Icon className="lucide" style={{ width: 18, height: 18 }} aria-hidden="true" />
{toast.message}
</div>
)
})}
</div>
</ToastContext.Provider>
)
}
/** Хук показа тостов. Должен использоваться внутри ToastProvider. */
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToast должен использоваться внутри <ToastProvider>')
return ctx
}

View File

@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,177 @@
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Сообщение чата в проводном формате сервера — 1:1 с pydantic-схемой
* `ChatMessageOut` backend (зафиксированный протокол WS).
* `created_at` — UTC ISO-8601 (в БД и API только UTC), конвертация в
* локальное время — на стороне ChatPanel (`lib/localTime.ts`).
*/
export interface ChatMessageOut {
id: number
author_id: string | null
author_name: string
is_guest: boolean
text: string
created_at: string
}
/**
* Статус WS-соединения чата: `connecting` — сокет открывается,
* `open` — транспорт открыт (auth уже отправлен, история может ещё
* загружаться), `closed` — соединение штатно закрыто/не запускалось,
* `error` — обрыв по невалидному токену или отказу в доступе
* (см. `statusMessage` для пояснения пользователю).
*/
export type ChatConnectionStatus = 'connecting' | 'open' | 'closed' | 'error'
type IncomingFrame =
| { type: 'history'; messages: ChatMessageOut[] }
| { type: 'message'; message: ChatMessageOut }
| { type: 'error'; code: string }
interface UseChatOptions {
/** id конференции — пока не известен (страница ещё не подключилась к LiveKit), WS не открываем. */
conferenceId: string | undefined
/** LiveKit-токен из JoinOut — им же авторизуем WS-сессию чата (см. протокол). */
token: string | undefined
/** `JoinOut.chat_enabled` — при `false` хук ничего не подключает. */
enabled: boolean
}
interface UseChatResult {
messages: ChatMessageOut[]
status: ChatConnectionStatus
/** Пояснение для баннера при обрыве/ошибке — `null`, если показывать нечего. */
statusMessage: string | null
/** `true` после close-кода 4404 (чат выключен на сервере/конференция не найдена) — панель нужно скрыть. */
unavailable: boolean
/** Отправить сообщение (1..2000 символов после strip, пустое/слишком длинное — игнорируется). */
sendMessage: (text: string) => void
}
/** Close-коды сервера — см. зафиксированный протокол WS. */
const CLOSE_TOKEN_INVALID = 4401
const CLOSE_FORBIDDEN = 4403
const CLOSE_UNAVAILABLE = 4404
/** Собрать WS-адрес чата от текущего `window.location` (ws:// на http, wss:// на https). */
function buildChatWsUrl(conferenceId: string): string {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${wsProtocol}//${window.location.host}/api/v1/conferences/${conferenceId}/chat`
}
/**
* WS-клиент чата комнаты конференции. Реализует зафиксированный протокол:
* connect → `{type:"auth"}` → `{type:"history"}` → далее входящие
* `{type:"message"}`/`{type:"error"}`.
*
* Optimistic-append собственных сообщений НЕ делается: сервер всегда
* присылает наше же сообщение обратно echo-фреймом `message` — если
* добавлять его на клиенте сразу при отправке, оно задублируется в списке.
*/
export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseChatResult {
const [messages, setMessages] = useState<ChatMessageOut[]>([])
// `wsStatus` меняется ТОЛЬКО из колбэков реального WS-соединения (см. ниже) —
// никогда синхронно в теле эффекта, иначе react-hooks/set-state-in-effect
// (эффект без активной подписки, только синхронизирующий производное
// значение, — по факту тот самый анти-паттерн, который правило и ловит).
// Начальное значение — `connecting`: типичный случай — подключение начинается
// сразу при монтировании хука (RoomPage монтируется один раз на комнату);
// при повторном подключении (смена conferenceId/token) статус до первого
// события нового сокета может на короткое время показывать значение от
// предыдущего соединения — не считается веб-сокет-ready до первого
// onopen/onerror/onclose. Когда чат выключен/данных для подключения ещё
// нет, наружу отдаём производный статус `closed` без всякого state — см.
// `status` ниже.
const [wsStatus, setWsStatus] = useState<ChatConnectionStatus>('connecting')
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const [unavailable, setUnavailable] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
const canConnect = enabled && Boolean(conferenceId) && Boolean(token)
useEffect(() => {
if (!canConnect || !conferenceId || !token) {
return
}
// Флаг «эффект пересоздан/компонент размонтирован» — без него обработчики
// СТАРОГО сокета (onclose и т.п.), сработавшие асинхронно уже после того,
// как эффект пересоздался для нового conferenceId/token, могли бы
// перетереть состояние уже актуального соединения.
let stale = false
const ws = new WebSocket(buildChatWsUrl(conferenceId))
wsRef.current = ws
ws.onopen = () => {
if (stale) return
setMessages([])
setStatusMessage(null)
setUnavailable(false)
setWsStatus('open')
ws.send(JSON.stringify({ type: 'auth', token }))
}
ws.onmessage = (event) => {
if (stale) return
let frame: IncomingFrame
try {
frame = JSON.parse(event.data as string) as IncomingFrame
} catch {
return
}
if (frame.type === 'history') {
setMessages(frame.messages)
} else if (frame.type === 'message') {
setMessages((prev) => [...prev, frame.message])
} else if (frame.type === 'error') {
// Ошибка отдельной операции (например, отклонённое сообщение) — соединение
// не рвётся, просто короткое пояснение пользователю.
setStatusMessage(`Ошибка чата: ${frame.code}`)
}
}
ws.onerror = () => {
if (stale) return
setWsStatus('error')
}
ws.onclose = (event) => {
wsRef.current = null
if (stale) return
if (event.code === CLOSE_TOKEN_INVALID) {
setWsStatus('error')
setStatusMessage('Сессия чата истекла — обновите страницу, чтобы переподключиться')
} else if (event.code === CLOSE_FORBIDDEN) {
setWsStatus('error')
setStatusMessage('Нет доступа к чату этой конференции')
} else if (event.code === CLOSE_UNAVAILABLE) {
setWsStatus('closed')
setUnavailable(true)
} else {
setWsStatus('closed')
}
}
return () => {
stale = true
wsRef.current = null
ws.close()
}
}, [canConnect, conferenceId, token])
const sendMessage = useCallback((text: string) => {
const trimmed = text.trim()
if (!trimmed || trimmed.length > 2000) return
const ws = wsRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'message', text: trimmed }))
}, [])
// Наружу — производный статус: пока подключаться нечем (выключено/нет
// conferenceId/token), всегда `closed`, даже если внутренний `wsStatus`
// ещё хранит значение от предыдущего подключения.
const status: ChatConnectionStatus = canConnect ? wsStatus : 'closed'
return { messages, status, statusMessage, unavailable, sendMessage }
}

View File

@@ -0,0 +1,48 @@
import { useCallback, useEffect, useState, type RefObject } from 'react'
import { useToast } from '@/components/ui/ToastProvider'
interface UseFullscreenResult {
/** `document.fullscreenEnabled` — если `false`, кнопку в тулбаре нужно скрыть (программный фуллскрин недоступен). */
supported: boolean
/** Активен ли полноэкранный режим сейчас. */
active: boolean
/** Войти/выйти из полноэкранного режима для контейнера `ref`. */
toggle: () => void
}
/**
* Полноэкранный режим комнаты конференции (Fullscreen API, MDN).
* Цель — корневой контейнер комнаты
* (`div[data-theme="room"]` в `RoomPage.tsx`), чтобы тулбар и чат оставались
* видны внутри фуллскрина.
*
* Единственный источник истины о состоянии — событие `fullscreenchange`
* документа, а не промис `requestFullscreen()`: выход по Esc браузер
* выполняет сам, без обратного вызова нашего кода, и событие — единственный
* способ об этом узнать.
*/
export function useFullscreen(ref: RefObject<HTMLElement | null>): UseFullscreenResult {
const toast = useToast()
const supported = typeof document !== 'undefined' && document.fullscreenEnabled
const [active, setActive] = useState(() => typeof document !== 'undefined' && document.fullscreenElement !== null)
useEffect(() => {
function handleChange() {
setActive(document.fullscreenElement !== null)
}
document.addEventListener('fullscreenchange', handleChange)
return () => document.removeEventListener('fullscreenchange', handleChange)
}, [])
const toggle = useCallback(() => {
if (document.fullscreenElement) {
void document.exitFullscreen()
return
}
ref.current?.requestFullscreen().catch(() => {
toast.show('Не удалось включить полноэкранный режим', 'error')
})
}, [ref, toast])
return { supported, active, toggle }
}

View File

@@ -0,0 +1,183 @@
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
import { useToast } from '@/components/ui/ToastProvider'
/** Какой вид мини-плеера сейчас активен — влияет на то, что рисовать в основном окне (см. `RoomPage.tsx`). */
type PiPMode = 'document' | 'video' | null
interface UseRoomPiPResult {
/** Доступен ли мини-плеер хоть в каком-то виде (Document PiP либо video-фолбэк) — `false` в Firefox, кнопку нужно скрыть. */
supported: boolean
/** Активен ли сейчас мини-плеер (в любом режиме). */
active: boolean
mode: PiPMode
/** Открытое PiP-окно — рендер сетки участников в него через `createPortal` (вызывающий код, `RoomPage.tsx`); `null` вне режима `'document'`. */
pipWindow: Window | null
/** Включить/выключить мини-плеер. ВАЖНО: вызывать СИНХРОННО из `onClick` — оба API требуют user gesture (transient activation), никакого `await` до первого вызова. */
toggle: () => void
}
/**
* Копирует таблицы стилей текущего документа в PiP-окно (рецепт MDN для
* Document Picture-in-Picture — окно открывается с пустым `<head>`, своих
* стилей не наследует). Внешние (cross-origin) таблицы, у которых `cssRules`
* недоступны из-за CORS, переносятся ссылкой (`<link>`), а не инлайном.
*/
function copyStyleSheets(target: Document) {
for (const sheet of Array.from(document.styleSheets)) {
try {
const cssText = Array.from(sheet.cssRules)
.map((rule) => rule.cssText)
.join('\n')
const style = target.createElement('style')
style.textContent = cssText
target.head.appendChild(style)
} catch {
if (sheet.href) {
const link = target.createElement('link')
link.rel = 'stylesheet'
link.href = sheet.href
target.head.appendChild(link)
}
}
}
}
/**
* Мини-плеер конференции («картинка в картинке»,
* Приоритет:
* 1. **Document Picture-in-Picture** (Chrome/Edge 116+) — активное окно (одна
* крупная плитка, без карусели/грида — см. `variant="pip"` у `RoomStage`)
* рисует вызывающий код через `createPortal(<RoomStage variant="pip"/>,
* pipWindow.document.body)`, React-контекст `LiveKitRoom` при этом
* остаётся в основном дереве (хуки треков продолжают работать). Основное
* окно вместо сцены показывает заглушку — см. `mode === 'document'` в
* `RoomPage.tsx`.
* 2. **Фолбэк video-PiP** (Safari, `document.pictureInPictureEnabled`) —
* классический `videoEl.requestPictureInPicture()` на видео активного
* спикера; сцена в основном окне остаётся как есть.
* 3. Ничего не поддерживается (Firefox) — `supported: false`, кнопку в
* тулбаре нужно СКРЫТЬ, а не задизейблить (ничего не обещаем).
*
* Переход в фуллскрин из PiP и обратно ничем дополнительно не
* синхронизируется: браузер сам выходит из фуллскрина при входе в PiP,
* `useFullscreen.active` обновится своим `fullscreenchange`.
*/
export function useRoomPiP(containerRef: RefObject<HTMLElement | null>): UseRoomPiPResult {
const toast = useToast()
const [pipWindow, setPipWindow] = useState<Window | null>(null)
const [videoPipActive, setVideoPipActive] = useState(false)
const hasDocumentPiP = typeof window !== 'undefined' && 'documentPictureInPicture' in window
const hasVideoPiP = typeof document !== 'undefined' && document.pictureInPictureEnabled
// Video-PiP фолбэк: состояние читаем из событий входа/выхода — они летят
// на document независимо от того, какой именно <video> сейчас в PiP.
useEffect(() => {
function handleEnter() {
setVideoPipActive(true)
}
function handleLeave() {
setVideoPipActive(false)
}
document.addEventListener('enterpictureinpicture', handleEnter, true)
document.addEventListener('leavepictureinpicture', handleLeave, true)
return () => {
document.removeEventListener('enterpictureinpicture', handleEnter, true)
document.removeEventListener('leavepictureinpicture', handleLeave, true)
}
}, [])
// Document PiP: пользователь может закрыть окно крестиком ОС, минуя нашу
// кнопку — единственный надёжный сигнал об этом, `pagehide` самого окна.
useEffect(() => {
if (!pipWindow) return
function handlePageHide() {
setPipWindow(null)
}
pipWindow.addEventListener('pagehide', handlePageHide)
return () => pipWindow.removeEventListener('pagehide', handlePageHide)
}, [pipWindow])
// Зеркало `pipWindow` в ref — для cleanup-эффекта ниже: при размонтировании
// самого хука (уход с RoomPage при активном PiP — навигация после
// onDisconnected, смена :slug и т.п.) эффект с пустыми зависимостями видит
// только значение на момент МОНТИРОВАНИЯ, если брать его из состояния
// напрямую; ref всегда содержит актуальное значение на момент unmount.
const pipWindowRef = useRef<Window | null>(null)
useEffect(() => {
pipWindowRef.current = pipWindow
}, [pipWindow])
// Закрыть осиротевшее PiP-окно при размонтировании хука — иначе при выходе
// из конференции (без явного «Вернуть в это окно») окно браузера остаётся
// висеть само по себе (WICG-рекомендация для Document PiP). Объявлен ПОСЛЕ
// эффекта с `pagehide`, поэтому при unmount его подписка уже снята до
// вызова `close()` — повторный `setPipWindow(null)` из `handlePageHide` не
// произойдёт (да и был бы безвреден, если бы порядок был другим: `close()`
// на уже закрытом окне — no-op, а обновление состояния уже
// размонтированного компонента React просто игнорирует).
useEffect(() => {
return () => {
pipWindowRef.current?.close()
}
}, [])
const toggle = useCallback(() => {
if (hasDocumentPiP) {
if (pipWindow) {
pipWindow.close()
setPipWindow(null)
return
}
// requestWindow вызывается СИНХРОННО прямо в обработчике клика — это и
// есть user gesture (transient activation); никакой асинхронной
// подготовки/await до этого вызова быть не должно.
window.documentPictureInPicture
?.requestWindow({ width: 480, height: 320 })
.then((win) => {
copyStyleSheets(win.document)
// Комната — всегда своя тёмная тема;
// `[data-theme="room"]` на <html> PiP-окна включает переменные
// --color-room-* из скопированного tokens.css.
win.document.documentElement.setAttribute('data-theme', 'room')
setPipWindow(win)
})
.catch(() => {
toast.show('Не удалось открыть мини-окно конференции', 'error')
})
return
}
if (hasVideoPiP) {
if (document.pictureInPictureElement) {
void document.exitPictureInPicture()
return
}
const container = containerRef.current
// `.lk-focus-layout video` без уточнения брал бы ПЕРВОЕ video в DOM —
// это миниатюра карусели (она в разметке идёт раньше фокус-плитки), а
// не крупный контент. Фокус-плитка — прямой ребёнок `.lk-focus-layout`
// (`> .lk-participant-tile`), карусельные — вложены внутрь `.lk-carousel`
// ещё на уровень глубже, поэтому прямой child-селектор берёт именно её —
// включая демонстрацию экрана, когда она в фокусе.
const video =
container?.querySelector<HTMLVideoElement>('.lk-focus-layout > .lk-participant-tile video') ??
container?.querySelector<HTMLVideoElement>('video')
if (!video) {
toast.show('Нет активного видео для мини-окна', 'error')
return
}
video.requestPictureInPicture().catch(() => {
toast.show('Не удалось открыть мини-окно видео', 'error')
})
}
}, [hasDocumentPiP, hasVideoPiP, pipWindow, containerRef, toast])
return {
supported: hasDocumentPiP || hasVideoPiP,
active: pipWindow !== null || videoPipActive,
mode: pipWindow ? 'document' : videoPipActive ? 'video' : null,
pipWindow,
toggle,
}
}

View File

@@ -0,0 +1,77 @@
import { useCallback, useEffect, useState } from 'react'
/**
* Ключ localStorage для явного выбора темы пользователем. ДОЛЖЕН совпадать с
* инлайн-скриптом в `frontend/index.html` (защита от FOUC — атрибут
* `data-theme` проставляется на `<html>` ДО первого рендера React).
*/
const STORAGE_KEY = 'vidconf-theme'
type ExplicitTheme = 'light' | 'dark'
function readStoredTheme(): ExplicitTheme | null {
try {
const value = localStorage.getItem(STORAGE_KEY)
return value === 'light' || value === 'dark' ? value : null
} catch {
// localStorage недоступен (приватный режим/политики браузера) — считаем,
// что явного выбора нет, тема определится системной prefers-color-scheme.
return null
}
}
function applyThemeAttribute(theme: ExplicitTheme | null) {
if (theme) {
document.documentElement.setAttribute('data-theme', theme)
} else {
document.documentElement.removeAttribute('data-theme')
}
}
/**
* Переключатель темы оболочки (см. design/DESIGN_SYSTEM.md §0.1,
* design/mockups/dark/README.md «Переключатель темы»).
*
* Контракт: явный выбор пользователя (`data-theme="light"`/`"dark"` на
* `<html>`) сохраняется в localStorage и переживает перезагрузку; пока
* выбора нет — действует системная тема (`prefers-color-scheme`), атрибут не
* проставлен вообще. В макетах переключатель — сегментированный контрол из
* двух явных кнопок «Светлая»/«Тёмная» (не циклический тумблер и без
* третьего состояния «системная» в UI) — клик по любой из них фиксирует
* выбор навсегда (до следующего клика по другой кнопке); вернуться к
* автодетекту без явного выбора через этот контрол нельзя, ровно как в
* утверждённых макетах.
*/
export function useTheme() {
const [explicit, setExplicit] = useState<ExplicitTheme | null>(() => readStoredTheme())
const [systemPrefersDark, setSystemPrefersDark] = useState(
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
)
// Живое отслеживание системной темы — чтобы подсветка активной кнопки
// оставалась верной, если пользователь ещё не делал явного выбора и сменил
// тему ОС, не покидая вкладку.
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (event: MediaQueryListEvent) => setSystemPrefersDark(event.matches)
media.addEventListener('change', handleChange)
return () => media.removeEventListener('change', handleChange)
}, [])
useEffect(() => {
applyThemeAttribute(explicit)
}, [explicit])
const setTheme = useCallback((theme: ExplicitTheme) => {
try {
localStorage.setItem(STORAGE_KEY, theme)
} catch {
// Сохранение недоступно — тема продержится до перезагрузки страницы.
}
setExplicit(theme)
}, [])
const resolved: ExplicitTheme = explicit ?? (systemPrefersDark ? 'dark' : 'light')
return { resolved, setTheme }
}

15
frontend/src/index.css Normal file
View File

@@ -0,0 +1,15 @@
@import "tailwindcss";
@import "tw-animate-css";
/* Дизайн-система VidConf — источник истины design/tokens.css (см. комментарий в файле). */
@import "./styles/tokens.css";
@import "./styles/shell.css";
@import "./styles/select.css";
/* Иконки lucide-react наследуют размер/обводку через класс .icon (см. tokens.css). */
svg.lucide {
width: 20px;
height: 20px;
stroke-width: 2;
flex-shrink: 0;
}

View File

@@ -0,0 +1,50 @@
/**
* Конвертация между локальным временем браузера (поля форм) и UTC ISO,
* который использует backend (в БД и API — только UTC,
* конвертация в локальную зону — на клиенте).
*/
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
/** IANA-таймзона браузера пользователя — для поля `timezone` правила повторения. */
export function browserTimeZone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone
}
/** Локальные дата ("YYYY-MM-DD") и время ("HH:MM") из момента (для прелоада полей формы). */
export function toLocalDateTimeParts(iso: string): { date: string; time: string } {
const d = new Date(iso)
return {
date: `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`,
time: `${pad2(d.getHours())}:${pad2(d.getMinutes())}`,
}
}
/** Собрать UTC ISO-момент из локальной даты ("YYYY-MM-DD") и времени ("HH:MM"). */
export function localDateTimeToIso(date: string, time: string): string {
return new Date(`${date}T${time}`).toISOString()
}
/** Отформатировать UTC ISO-момент в локальную дату+время для карточек («21 июля, 15:00»). */
export function formatLocalDateTime(iso: string): string {
const d = new Date(iso)
const dateLabel = d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })
const timeLabel = d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
return `${dateLabel}, ${timeLabel}`
}
/** Отформатировать UTC ISO-момент как локальное время «14:02» (для сообщений чата). */
export function formatLocalTime(iso: string): string {
return new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
}
/** Отформатировать UTC ISO-момент как «вт, 22 июля, 11:00» (для «Ближайшее»). */
export function formatNextOccurrence(iso: string): string {
const d = new Date(iso)
const weekday = d.toLocaleDateString('ru-RU', { weekday: 'short' }).replace('.', '')
const dateLabel = d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })
const timeLabel = d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
return `${weekday}, ${dateLabel}, ${timeLabel}`
}

View File

@@ -0,0 +1,81 @@
/**
* Разбор пользовательского ввода на экране подключения к конференции
* (JoinPage, поле «Ссылка или номер») в значение, которое можно передать
* backend'у как `q` в `GET /conferences/resolve?q=` — это либо slug конференции,
* либо её номер (цифры, возможно с пробелами).
*
* Поддерживаемые форматы входной строки:
* - голый slug: `oEjfD0s-XJg`;
* - голый номер, в т.ч. с пробелами: `8842 104`;
* - полная ссылка с любым origin и путём приглашения `/j/{slug}`:
* `https://vidconf.ru/j/oEjfD0s-XJg`, `vidconf.ru/j/oEjfD0s-XJg`;
* - полная ссылка на саму комнату `/room/{slug}` (её видно в адресной строке
* уже во время звонка и её тоже логично уметь вставить обратно):
* `http://localhost:5173/room/aCFxP2REfhw`;
* - с «хвостом» query/fragment после slug: `.../j/oEjfD0s-XJg?ref=email#x`.
*
* Тест-раннер (vitest/jest) в проекте не настроен (см. package.json) — вместо
* юнит-тестов ниже приведены примеры поведения в комментариях; при появлении
* тест-раннера их можно почти дословно перенести в `*.test.ts`.
*
* @example
* parseJoinQuery('8842 104') // '8842 104' (номер с пробелом — как есть)
* parseJoinQuery('oEjfD0s-XJg') // 'oEjfD0s-XJg' (голый slug — как есть)
* parseJoinQuery('vidconf.ru/j/oEjfD0s-XJg') // 'oEjfD0s-XJg'
* parseJoinQuery('https://vidconf.ru/j/oEjfD0s-XJg?ref=email') // 'oEjfD0s-XJg'
* parseJoinQuery('http://localhost:5173/room/aCFxP2REfhw') // 'aCFxP2REfhw'
* parseJoinQuery(' https://vidconf.ru/j/x-y#section ') // 'x-y'
*/
/** Путь приглашения (`/j/`) или прямая ссылка на комнату (`/room/`) — оба ведут к одному и тому же slug. */
const JOIN_PATH_SEGMENT = /\/(?:j|room)\/([^/?#]+)/i
/** Похоже на домен (`vidconf.ru/...`) без явной схемы `http(s)://`. */
const BARE_DOMAIN_LIKE = /^[\w-]+(?:\.[a-z]{2,})+(?::\d+)?(\/|$)/i
/** Явная схема URL (`http://`, `https://`, в теории и другие). */
const URL_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i
function safeDecode(value: string): string {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
/** Отрезает query (`?...`) и fragment (`#...`) от конца строки, если они есть. */
function stripQueryAndFragment(value: string): string {
const idx = value.search(/[?#]/)
return idx === -1 ? value : value.slice(0, idx)
}
/**
* Извлекает slug или номер конференции из произвольного пользовательского
* ввода (ссылка любого вида или номер). Если распознать путь `/j/`/`/room/`
* не удалось — возвращает исходную строку без схемы/домена (если они были)
* либо как есть, чтобы `resolveConference` сам решил, валидна она или нет
* (публичный эндпоинт и так отвечает единообразным 404, не палит различие
* между «неверный формат» и «не найдено»).
*/
export function parseJoinQuery(raw: string): string {
const trimmed = raw.trim()
if (!trimmed) return ''
const segmentMatch = trimmed.match(JOIN_PATH_SEGMENT)
if (segmentMatch) {
return safeDecode(segmentMatch[1])
}
const looksLikeUrl = URL_SCHEME.test(trimmed) || BARE_DOMAIN_LIKE.test(trimmed)
if (looksLikeUrl) {
const withoutScheme = trimmed.replace(URL_SCHEME, '')
const slashIndex = withoutScheme.indexOf('/')
if (slashIndex !== -1) {
const rest = stripQueryAndFragment(withoutScheme.slice(slashIndex + 1))
if (rest) return safeDecode(rest)
}
}
return stripQueryAndFragment(trimmed).trim() || trimmed
}

View File

@@ -0,0 +1,17 @@
/** Склонение русского слова «участник» по числу. */
export function pluralizeParticipants(count: number): string {
const mod10 = count % 10
const mod100 = count % 100
if (mod10 === 1 && mod100 !== 11) return 'участник'
if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100)) return 'участника'
return 'участников'
}
/** Склонение русского слова «конференция» по числу. */
export function pluralizeConferences(count: number): string {
const mod10 = count % 10
const mod100 = count % 100
if (mod10 === 1 && mod100 !== 11) return 'конференция'
if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100)) return 'конференции'
return 'конференций'
}

View File

@@ -0,0 +1,144 @@
/**
* Форматирование и лёгкий клиентский предпросмотр правила повторения
* конференции (`ConferenceRecurrence`). Источник истины по развёртке —
* backend (`backend/services/recurrence.py::expand_occurrences`); функции
* ниже — только для UI-подсказок в форме создания/редактирования
* («ближайшие вхождения») и подписей в карточках календаря/«Моих
* конференций» — точность до секунды/переход через полночь не гарантируется.
*/
import type { ConferenceRecurrence, RecurrenceType } from '@/api/conferences'
/** Короткие подписи дней недели для чипов (0 — понедельник .. 6 — воскресенье). */
export const WEEKDAY_SHORT = ['ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ', 'СБ', 'ВС'] as const
const WEEKDAY_DATIVE_PLURAL = [
'понедельникам',
'вторникам',
'средам',
'четвергам',
'пятницам',
'субботам',
'воскресеньям',
] as const
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
/** Прибавить минуты к времени вида "HH:MM" (без учёта перехода через полночь на новый день). */
export function addMinutesToTime(hhmm: string, minutes: number): string {
const [h, m] = hhmm.split(':').map(Number)
const total = ((h * 60 + m + minutes) % (24 * 60) + 24 * 60) % (24 * 60)
return `${pad2(Math.floor(total / 60))}:${pad2(total % 60)}`
}
/** Соединить список строк по-русски («а» / «а и б» / «а, б и в»). */
function joinRu(items: string[]): string {
if (items.length === 0) return ''
if (items.length === 1) return items[0]
return `${items.slice(0, -1).join(', ')} и ${items[items.length - 1]}`
}
/** Человекочитаемое описание правила повторения + диапазон времени вхождения. */
export function formatRecurrenceSummary(recurrence: ConferenceRecurrence): string {
const range = `${recurrence.time_local}${addMinutesToTime(recurrence.time_local, recurrence.duration_minutes)}`
switch (recurrence.type) {
case 'weekly': {
const days = [...(recurrence.weekdays ?? [])].sort((a, b) => a - b).map((w) => WEEKDAY_DATIVE_PLURAL[w])
return `По ${joinRu(days)}, ${range}`
}
case 'biweekly': {
const days = [...(recurrence.weekdays ?? [])].sort((a, b) => a - b).map((w) => WEEKDAY_DATIVE_PLURAL[w])
return `Раз в 2 недели, по ${joinRu(days)}, ${range}`
}
case 'monthly':
return `Раз в месяц, ${recurrence.day_of_month}-го числа, ${range}`
case 'every_n_days':
return `Каждые ${recurrence.interval_days} дней, ${range}`
default:
return range
}
}
/** Разобрать дату вида "YYYY-MM-DD" в локальную полночь (без смещения часового пояса). */
function parseDateOnly(value: string): Date {
const [y, m, d] = value.split('-').map(Number)
return new Date(y, m - 1, d)
}
function startOfWeek(d: Date): Date {
const jsWeekday = (d.getDay() + 6) % 7 // 0 — понедельник
const out = new Date(d)
out.setDate(out.getDate() - jsWeekday)
out.setHours(0, 0, 0, 0)
return out
}
function matchesCalendarDate(
type: RecurrenceType,
weekdays: number[],
dayOfMonth: number | undefined,
intervalDays: number | undefined,
anchor: Date,
candidate: Date,
): boolean {
const ourWeekday = (candidate.getDay() + 6) % 7
if (type === 'weekly') return weekdays.includes(ourWeekday)
if (type === 'biweekly') {
if (!weekdays.includes(ourWeekday)) return false
const weeksDiff = Math.round((startOfWeek(candidate).getTime() - startOfWeek(anchor).getTime()) / (7 * 86_400_000))
return ((weeksDiff % 2) + 2) % 2 === 0
}
if (type === 'monthly') {
const lastDay = new Date(candidate.getFullYear(), candidate.getMonth() + 1, 0).getDate()
const clamped = Math.min(dayOfMonth ?? 1, lastDay)
return candidate.getDate() === clamped
}
if (type === 'every_n_days') {
if (candidate < anchor) return false
const daysDiff = Math.round((candidate.getTime() - anchor.getTime()) / 86_400_000)
return daysDiff % Math.max(1, intervalDays ?? 1) === 0
}
return false
}
/**
* Приблизительный клиентский предпросмотр ближайших `count` дат вхождения —
* только для подсказки в форме (см. заголовок файла).
*/
export function previewNextDates(
recurrence: Pick<ConferenceRecurrence, 'type' | 'weekdays' | 'day_of_month' | 'interval_days' | 'anchor_date'>,
count = 2,
): Date[] {
const anchor = parseDateOnly(recurrence.anchor_date)
const today = new Date()
today.setHours(0, 0, 0, 0)
const start = anchor > today ? anchor : today
const result: Date[] = []
const cursor = new Date(start)
for (let i = 0; i < 3660 && result.length < count; i++) {
if (
matchesCalendarDate(
recurrence.type,
recurrence.weekdays ?? [],
recurrence.day_of_month,
recurrence.interval_days,
anchor,
cursor,
)
) {
result.push(new Date(cursor))
}
cursor.setDate(cursor.getDate() + 1)
}
return result
}
const DATE_HINT_FORMAT: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long' }
/** Отформатировать список дат для подсказки вида «ближайшие: вторник 21 июля, четверг 23 июля». */
export function formatPreviewHint(dates: Date[]): string {
if (dates.length === 0) return ''
const labels = dates.map((d) => d.toLocaleDateString('ru-RU', DATE_HINT_FORMAT))
return `Ближайшие: ${joinRu(labels)}`
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

67
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// Шрифты дизайн-системы — self-hosted через @fontsource (без внешних CDN),
// импортируются ДО ./index.css, чтобы @font-face объявились раньше стилей.
// Агрегатные <вес>.css включают все subset'ы, включая кириллицу (интерфейс
// русский). Веса — по DESIGN_SYSTEM.md: Unbounded 400/600/700 (display),
// Inter 400/500/600/700 (body), JetBrains Mono 500 (mono).
import '@fontsource/unbounded/400.css'
import '@fontsource/unbounded/600.css'
import '@fontsource/unbounded/700.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/600.css'
import '@fontsource/inter/700.css'
import '@fontsource/jetbrains-mono/500.css'
import './index.css'
import App from './App.tsx'
import { AuthProvider } from '@/auth/AuthProvider'
import { ToastProvider } from '@/components/ui/ToastProvider'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Данные почти всегда актуализируются через собственные refetchInterval —
// не рефетчим агрессивно при каждом фокусе окна.
refetchOnWindowFocus: false,
retry: 1,
},
},
})
/*
* Без <StrictMode>: намеренно, из-за экрана конференции (RoomPage).
*
* `@livekit/components-react` <LiveKitRoom> (хук useLiveKitRoom) создаёт
* `Room` в useEffect и вызывает `room.disconnect()` в cleanup-эффекте при
* размонтировании. Двойной вызов эффектов StrictMode в dev (mount → cleanup
* → mount) укладывается в ровно тот гоночный сценарий, от которого сама
* библиотека предупреждает в комментарии к своему (неиспользуемому внутри
* <LiveKitRoom>) хуку useSequentialRoomConnectDisconnect: «room.connect() в
* теле эффекта может продолжить выполняться, пока ещё не завершился
* room.disconnect() из cleanup предыдущего рендера» — т.е. первый Room
* успевает частично подключиться (в т.ч. опубликовать локальный трек камеры
* с реальным sid) и почти сразу схлопывается вместе со вторым, из-за чего
* реальный (переживший гонку) Room получает событие Disconnected и роняет
* нас в /lobby через onDisconnected, а GridLayout/useVisualStableUpdate
* параллельно ловит смену идентичности трека (placeholder → реальный трек
* → снова placeholder) и логирует `Error while running updatePages()`.
* Внутри `<LiveKitRoom>` эта гонка не сериализуется (в отличие от
* useSequentialRoomConnectDisconnect), поэтому единственный надёжный фикс на
* уровне приложения — не выполнять эффекты дважды. В production-сборке
* StrictMode всё равно не даёт двойного вызова эффектов, так что для
* собранного бандла разницы нет — риск только в dev-режиме, где мы и теряли
* подключение к комнате.
*/
createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<AuthProvider>
<App />
</AuthProvider>
</ToastProvider>
</QueryClientProvider>
</BrowserRouter>,
)

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

View File

@@ -0,0 +1,150 @@
/*
* VidConf — стили страницы «Администрирование».
* Табличные/toolbar-паттерны — 1:1 из design/mockups/admin.html
* (.section-tab/.toolbar-row/.search-wrap/.table-card/.badge-role-*).
* Вкладка «Настройки» в макете отсутствует — построена по тому же
* визуальному языку (.settings-card повторяет .table-card/.invite-card).
* page-shell/page-main/page-head — общие из lobby.css; кнопки, модалки и
* переключатель switch — из shell.css и calendar.css.
*
* Все правила скопированы под `.admin-page`, чтобы избежать конфликта имён
* классов (`.icon-btn`, `.chip`) с другими светлыми страницами оболочки —
* в проекте уже был прецедент такой коллизии (см. комментарий в
* my-conferences.css про порядок правил в исходнике).
*/
.admin-page .section-tabs { display: flex; gap: var(--space-2); margin-bottom: var(--space-5); border-bottom: 1px solid var(--color-border); }
.admin-page .section-tab { padding: 10px 4px; margin-right: var(--space-6); background: none; border: none; border-bottom: 2px solid transparent; font: var(--text-body-lg); font-weight: 600; color: var(--color-ink-500); cursor: pointer; }
.admin-page .section-tab:hover { color: var(--color-ink-900); }
.admin-page .section-tab.is-active { color: var(--color-ink-700); border-color: var(--color-accent-active); }
.admin-page .section-tabs-inline { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: var(--space-4); }
.admin-page .chip { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: var(--radius-full); border: 1.5px solid var(--color-border); background: var(--color-surface); font: var(--text-body); font-weight: 600; color: var(--color-ink-500); cursor: pointer; }
.admin-page .chip.is-active { border-color: var(--color-ink-700); color: var(--color-ink-700); background: var(--color-bg-alt); }
.admin-page .toolbar-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-5); gap: var(--space-4); flex-wrap: wrap; }
/* Группа «поиск + кнопка действия» слева в toolbar-row («Добавить
пользователя») — без неё третий элемент в flex-контейнере с
justify-content: space-between распределился бы отдельной колонкой, а не
встал рядом с поиском. */
.admin-page .toolbar-left { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.admin-page .search-wrap { position: relative; width: 320px; max-width: 100%; }
.admin-page .search-wrap .icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--color-ink-400); }
/* color/::placeholder — намеренно НЕ в тёмном скоупе: раньше их не было
вовсе, текст/плейсхолдер рисовались UA-дефолтом браузера, а не хардкодом
светлой темы, — в светлой это давало почти совпадающий с ink-900/ink-300
результат, а в тёмной ломалось (тёмный UA-текст на тёмном фоне). var()
резолвится в обеих темах сам, поэтому одно правило на все три скоупа
корректно и для светлой, и для тёмной — оборачивать не в чем и незачем. */
.admin-page .search-wrap input { width: 100%; font: var(--text-body); color: var(--color-ink-900); padding: 9px 12px 9px 38px; border-radius: var(--radius-full); border: 1px solid var(--color-border); background: var(--color-surface); }
.admin-page .search-wrap input::placeholder { color: var(--color-ink-300); }
.admin-page .search-wrap input:focus { outline: none; border-color: var(--color-focus-ring); }
.admin-page .toolbar-count { font: var(--text-body); color: var(--color-ink-500); white-space: nowrap; }
.admin-page .table-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-2xl); overflow: hidden; margin-bottom: var(--space-6); }
.admin-page table { width: 100%; border-collapse: collapse; }
.admin-page thead th { text-align: left; background: var(--color-bg-alt); font: var(--text-caption); color: var(--color-ink-500); padding: 12px 20px; text-transform: uppercase; }
.admin-page tbody td { padding: 14px 20px; font: var(--text-body-lg); color: var(--color-ink-900); border-top: 1px solid var(--color-border); }
.admin-page tbody tr:hover { background: var(--color-bg-alt); }
.admin-page .badge-role-admin { background: var(--color-ink-700); color: #fff; }
.admin-page .badge-role-user { background: var(--color-bg-alt); color: var(--color-ink-500); }
.admin-page .row-actions { display: flex; gap: 6px; }
.admin-page .icon-btn { width: 32px; height: 32px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-ink-500); display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; }
.admin-page .icon-btn:hover:not(:disabled) { border-color: var(--color-border-strong); color: var(--color-ink-900); }
.admin-page .icon-btn.danger:hover:not(:disabled) { border-color: var(--color-danger); color: var(--color-danger); background: var(--color-danger-bg); }
.admin-page .icon-btn:disabled { opacity: .45; cursor: not-allowed; }
.admin-page .user-cell { display: flex; align-items: center; gap: 10px; }
.admin-page .user-cell .avatar { width: 32px; height: 32px; font-size: 12px; }
.admin-page .user-cell div span { display: block; font: 13px var(--font-body); color: var(--color-ink-500); }
/* Клик по имени открывает карточку профиля — визуально
остаётся обычным текстом (не похоже на ссылку), подчёркивание — только hover. */
.admin-page .user-cell-name { background: none; border: none; padding: 0; font: inherit; color: inherit; cursor: pointer; text-align: left; }
.admin-page .user-cell-name:hover { text-decoration: underline; color: var(--color-ink-700); }
.admin-page select { font: var(--text-body); color: var(--color-ink-900); padding: 6px 10px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: var(--color-surface); font-family: var(--font-body); }
.admin-page select:disabled { opacity: .5; cursor: not-allowed; }
.admin-page select:focus { outline: none; border-color: var(--color-focus-ring); }
.admin-page .pagination-row { display: flex; justify-content: center; gap: var(--space-3); margin-bottom: var(--space-8); }
/* ---------- Вкладка «Команды» (нет в макете — паттерн toolbar-row/table-card) ---------- */
/* color/::placeholder — то же обоснование, что у .search-wrap input выше. */
.admin-page .team-add-form { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.admin-page .team-add-form input { width: 280px; max-width: 100%; font: var(--text-body); color: var(--color-ink-900); padding: 9px 14px; border-radius: var(--radius-full); border: 1px solid var(--color-border); background: var(--color-surface); }
.admin-page .team-add-form input::placeholder { color: var(--color-ink-300); }
.admin-page .team-add-form input:focus { outline: none; border-color: var(--color-focus-ring); }
.admin-page .team-rename-form input { width: 240px; max-width: 100%; font: var(--text-body-lg); color: var(--color-ink-900); padding: 6px 10px; border-radius: var(--radius-md); border: 1px solid var(--color-border-strong); background: var(--color-surface); }
.admin-page .team-rename-form input:focus { outline: none; border-color: var(--color-focus-ring); }
/* ---------- Вкладка «Настройки» (нет в макете — паттерн table-card/invite-card) ---------- */
/* Соседние карточки внутри строки грида должны быть одной высоты: без
align-items:stretch (значение по умолчанию для grid) каждая плитка
сжималась бы по своему контенту, и при разном наполнении («Модули» с
четырьмя переключателями против правой колонки, «Рассылка саммари»
против «Часового пояса») ряд выглядел рваным. */
.admin-page .settings-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-5); }
.admin-page .settings-card { display: flex; flex-direction: column; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-2xl); padding: var(--space-6); }
.admin-page .settings-card h2 { font: var(--text-h2); margin: 0 0 4px; color: var(--color-ink-900); }
.admin-page .settings-card .desc { font: var(--text-body); color: var(--color-ink-500); margin: 0 0 var(--space-4); }
.admin-page .settings-actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; }
/* Первый ряд грида: левая плитка «Модули» (grid-item, растягивается по
умолчанию align-items:stretch) и правая колонка — вертикальный стек из
«Уровень AI» и «Эталон mail-домена». Стек как grid-item тоже растягивается
до высоты ряда; внутри него flex-grow на карточках распределяет остаток
высоты поровну между двумя правыми плитками, поэтому итоговая высота
левой и суммарная высота правых двух совпадают без фиксации пикселей.
Важно: flex-basis только auto и flex-shrink:0 — карточка не должна
ужиматься ниже высоты своего контента (список уровней AI/подпись+инпут),
иначе при `flex-basis: 0` контент переполняет сжатый бокс и визуально
наезжает на соседнюю плитку снизу (overflow: visible по умолчанию не
обрезает переполнение, а просто рисует поверх соседа). */
.admin-page .settings-stack { display: flex; flex-direction: column; gap: var(--space-5); }
.admin-page .settings-stack .settings-card { flex: 1 0 auto; }
/* Инпут эталонного домена дизейблится при выключенном тумблере верификации,
но значение поля при этом не сбрасывается (сохраняется в стейте формы). */
.admin-page .settings-card .field input:disabled { opacity: .55; cursor: not-allowed; background: var(--color-bg-alt); }
/* Тело карточки занимает всё оставшееся место после заголовка/описания —
именно за счёт flex:1 плитка ниже соседней по контенту получает
аккуратно распределённую пустоту, а не пустой хвост снизу. */
.admin-page .settings-card-body { display: flex; flex-direction: column; flex: 1; }
.admin-page .settings-card-body--spread { justify-content: space-between; }
.admin-page .settings-card-body--center { justify-content: center; }
.admin-page .ai-level-list { display: flex; flex-direction: column; gap: var(--space-3); }
.admin-page .ai-level-option { display: flex; align-items: flex-start; gap: 10px; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); cursor: pointer; }
.admin-page .ai-level-option:hover { border-color: var(--color-border-strong); }
.admin-page .ai-level-option.is-disabled { cursor: not-allowed; opacity: .6; }
.admin-page .ai-level-option input[type="radio"] { margin-top: 3px; width: 16px; height: 16px; accent-color: var(--color-ink-700); flex-shrink: 0; }
.admin-page .ai-level-option strong { display: block; font: var(--text-body); font-weight: 600; color: var(--color-ink-900); }
/* align-items: flex-start (не center) — причины недоступности
Блок 1) могут быть длинными («модель X не скачана — запустите install.sh
с пресетом N») и переноситься на несколько строк; при center иконка
съезжала бы к середине блока текста, а не к его первой строке. */
.admin-page .ai-level-reason { display: flex; align-items: flex-start; gap: 5px; font: var(--text-caption); color: var(--color-warning); margin-top: 4px; }
.admin-page .ai-level-reason svg { flex-shrink: 0; margin-top: 1px; }
@media (max-width: 960px) {
.admin-page .settings-grid { grid-template-columns: 1fr; }
}
/* ---------- Футер админки ----------
Версия инстанса из `/api/health` (см. AdminPage.tsx) — до появления поля
на backend строка сокращается до одного «VidConf» без версии. */
.admin-page .admin-footer { margin-top: var(--space-6); padding-top: var(--space-4); border-top: 1px solid var(--color-border); font: var(--text-caption); color: var(--color-ink-400); text-align: center; }
/* =====================================================================
* Тёмная тема: заливка var(--color-ink-700) (индиго) сочеталась с жёстким
* color:#fff; в тёмной теме ink-700 = мята (светлый тон) — белый текст
* перестаёт читаться, заменено на var(--color-accent-text) (тёмный, для
* заливки мятой). См. design/mockups/dark/README.md, категория 1.
* ===================================================================== */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .admin-page .badge-role-admin { color: var(--color-accent-text); }
}
:root[data-theme='dark'] .admin-page .badge-role-admin { color: var(--color-accent-text); }

View File

@@ -0,0 +1,270 @@
/*
* VidConf — стили экранов auth (вход/регистрация/подтверждение email).
* 1:1 воспроизводит design/mockups/auth.html.
*/
.layout { display: flex; min-height: 100vh; }
/* ---------- Брендовая панель ---------- */
.brand-panel {
width: 42%;
background: linear-gradient(160deg, #EDEFFA 0%, #F1F1F1 55%, #F7F7F8 100%);
padding: var(--space-12);
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
overflow: hidden;
border-right: 1px solid var(--color-border);
}
.brand-panel::after {
content: "";
position: absolute;
right: -120px;
bottom: -120px;
width: 380px;
height: 380px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, rgba(212, 242, 227, 0.45), rgba(212, 242, 227, 0) 70%);
}
.brand-copy { z-index: 1; }
.eyebrow {
font: var(--text-caption);
letter-spacing: 0.08em;
color: var(--color-ink-400);
margin: 0 0 var(--space-4);
}
.brand-headline {
font: var(--text-display-lg);
font-family: var(--font-display);
color: var(--color-ink-700);
margin: 0 0 var(--space-4);
max-width: 460px;
}
.brand-sub {
font: var(--text-body-lg);
color: var(--color-ink-500);
max-width: 420px;
margin: 0;
}
.brand-stats { display: flex; gap: var(--space-4); z-index: 1; margin-top: var(--space-8); }
.stat-glass {
background: var(--color-surface-glass);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.7);
box-shadow: var(--shadow-glass);
border-radius: var(--radius-xl);
padding: var(--space-4) var(--space-5);
min-width: 140px;
}
.stat-glass strong { display: block; font: var(--text-h1); font-family: var(--font-display); color: var(--color-ink-700); }
.stat-glass span { font: var(--text-caption); color: var(--color-ink-500); text-transform: none; letter-spacing: normal; }
/* ---------- Форма-панель ---------- */
.form-panel {
width: 58%;
padding: var(--space-12) var(--space-16);
display: flex;
flex-direction: column;
justify-content: center;
}
.screen-block { max-width: 440px; width: 100%; }
.screen-title { font: var(--text-h1); color: var(--color-ink-900); margin: 0 0 var(--space-8); }
.tabs { display: flex; gap: var(--space-2); margin-bottom: var(--space-6); background: var(--color-bg-alt); padding: 4px; border-radius: var(--radius-full); width: fit-content; }
.tab {
padding: 8px 20px;
border-radius: var(--radius-full);
border: none;
background: transparent;
font: var(--text-body);
color: var(--color-ink-500);
text-decoration: none;
display: inline-flex;
}
.tab.is-active { background: var(--color-surface); color: var(--color-ink-700); font-weight: 600; box-shadow: var(--shadow-sm); }
.field { margin-bottom: var(--space-5); }
.field label { display: block; font: var(--text-body); font-weight: 600; color: var(--color-ink-900); margin-bottom: var(--space-2); }
.input-wrap { position: relative; display: flex; align-items: center; }
.input-wrap .field-icon { position: absolute; left: 14px; color: var(--color-ink-400); pointer-events: none; }
.input-wrap input, .input-wrap select {
width: 100%;
font: var(--text-body-lg);
font-family: var(--font-body);
color: var(--color-ink-900);
padding: 11px 14px 11px 42px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
outline: none;
}
.input-wrap input::placeholder { color: var(--color-ink-300); }
.input-wrap input:hover, .input-wrap select:hover { border-color: var(--color-border-strong); }
.input-wrap input:focus, .input-wrap select:focus { border-color: var(--color-focus-ring); box-shadow: 0 0 0 3px rgba(59, 77, 149, 0.12); }
/* Нативный select в WebKit/Safari игнорирует border/border-radius/background
без явного отключения UA-рендеринга: без appearance:none контрол рисуется
системным виджетом («аквариумная» рамка ОС) поверх авторских стилей — этим
объясняется «браузерный дефолт» без скругления и с меньшей высотой. */
.input-wrap select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
padding-right: 38px;
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236976AC' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
background-size: 16px;
}
.input-wrap .toggle-eye {
position: absolute;
right: 14px;
left: auto;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--color-ink-400);
display: flex;
}
.field.has-error input { border-color: var(--color-danger); box-shadow: 0 0 0 3px rgba(199, 54, 79, 0.1); }
.error-msg { display: flex; align-items: center; gap: 6px; margin-top: var(--space-2); font: var(--text-body); color: var(--color-danger); }
.hint-msg { margin-top: var(--space-2); font: var(--text-body); color: var(--color-ink-500); }
.row-between { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-6); }
.checkbox-row { display: flex; align-items: center; gap: 8px; font: var(--text-body); color: var(--color-ink-500); }
.link { color: var(--color-ink-700); font-weight: 600; text-decoration: none; background: none; border: none; cursor: pointer; font: var(--text-body); }
.link:hover { text-decoration: underline; }
.btn-block { width: 100%; }
.btn-primary.btn-lg { font: var(--text-body-lg); font-weight: 700; padding: 13px 24px; }
.btn-secondary.btn-lg { font: var(--text-body-lg); font-weight: 600; padding: 12px 24px; }
/* ---------- Подтверждение email ---------- */
.confirm-card {
max-width: 440px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-2xl);
padding: var(--space-8);
text-align: left;
}
.confirm-icon {
width: 56px;
height: 56px;
border-radius: var(--radius-full);
background: var(--color-success-bg);
color: var(--color-success);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-5);
}
.confirm-card h3 { font: var(--text-h2); color: var(--color-ink-900); margin: 0 0 var(--space-2); }
.confirm-card p { font: var(--text-body-lg); color: var(--color-ink-500); margin: 0 0 var(--space-6); }
.confirm-card .mail-chip {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--color-bg-alt);
border-radius: var(--radius-full);
padding: 6px 14px;
font: var(--text-mono-sm);
color: var(--color-ink-700);
margin-bottom: var(--space-6);
}
.confirm-card.pending .confirm-icon { background: var(--color-warning-bg); color: var(--color-warning); }
.confirm-card.error .confirm-icon { background: var(--color-danger-bg); color: var(--color-danger); }
/* ---------- Переключатель темы: плавающая плашка (см. AuthLayout.tsx) ---------- */
.theme-toggle--floating {
position: fixed;
top: 24px;
right: 32px;
z-index: 50;
box-shadow: var(--shadow-md);
}
/* =====================================================================
* Тёмная тема: точечные патчи (design/mockups/dark/README.md, категория
* «декоративные градиенты/тени») — литеральные rgba построены на индиго/
* старом danger-hex светлой темы, поэтому не перекрашиваются через var().
* ===================================================================== */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .brand-panel {
background: linear-gradient(160deg, #242424 0%, #1E1E1E 55%, #1A1A1A 100%);
}
:root:not([data-theme]) .brand-panel::after {
background: radial-gradient(circle at 30% 30%, rgba(127, 221, 168, 0.16), rgba(127, 221, 168, 0) 70%);
}
:root:not([data-theme]) .stat-glass { border-color: rgba(255, 255, 255, 0.08); }
}
:root[data-theme='dark'] .brand-panel {
background: linear-gradient(160deg, #242424 0%, #1E1E1E 55%, #1A1A1A 100%);
}
:root[data-theme='dark'] .brand-panel::after {
background: radial-gradient(circle at 30% 30%, rgba(127, 221, 168, 0.16), rgba(127, 221, 168, 0) 70%);
}
:root[data-theme='dark'] .stat-glass { border-color: rgba(255, 255, 255, 0.08); }
/* ---------- Узкие экраны ---------- */
/* ВАЖНО: этот блок должен идти строго ПОСЛЕДНИМ в файле — при равной
специфичности с безусловными правилами выше (`.brand-panel`, `.form-panel`
и т.п. — те же 0,1,0) побеждает порядок в исходнике, а не сам факт
совпадения media query (тот же баг уже ловили в lobby.css/.action, гейт 3
— см. комментарий там). Макет auth.html — desktop-only
(`html { min-width: 1280px }`), адаптива в нём нет.
Причина исходного бага: `.layout` — flex-row из двух панелей с width
в процентах и без flex-shrink:0, а `.brand-panel` — overflow:hidden (нужен
для декоративного круга). По спецификации flex, overflow:hidden отключает
автоматический min-width:auto (= min-content) и делает его 0 — поэтому
при узком viewport панель схлопывалась намного уже своего текста, а не
переносила его: overflow:hidden просто обрезал то, что не поместилось,
вместо переноса строк. Решение — не подбирать min-width, а на узких
экранах складывать колонки в одну: тогда ширина каждой панели равна 100%
и конкуренции за пространство между ними нет вовсе. */
@media (max-width: 900px) {
.layout {
flex-direction: column;
min-height: auto;
}
.brand-panel {
width: 100%;
min-width: 0;
padding: var(--space-8) var(--space-6) var(--space-6);
border-right: none;
border-bottom: 1px solid var(--color-border);
}
.brand-panel::after {
width: 220px;
height: 220px;
right: -90px;
bottom: -90px;
}
.brand-headline { max-width: none; }
.brand-sub { max-width: none; }
.brand-stats { flex-wrap: wrap; }
.stat-glass { flex: 1 1 140px; min-width: 0; }
.form-panel {
width: 100%;
min-width: 0;
padding: var(--space-8) var(--space-6);
justify-content: flex-start;
}
.screen-block { max-width: 100%; }
}
@media (max-width: 480px) {
/* Совсем узкий телефон (375px) — экономим отступы и убираем декоративный
круг: на компактном hero он ближе к тексту и уже не читается как акцент. */
.brand-panel { padding: var(--space-6) var(--space-4); }
.brand-panel::after { display: none; }
.brand-stats { gap: var(--space-3); }
.stat-glass { flex-basis: 100%; }
.form-panel { padding: var(--space-6) var(--space-4) var(--space-10); }
}

View File

@@ -0,0 +1,273 @@
/*
* VidConf — стили экрана календаря конференций: менеджер
* запланированных/закреплённых конференций (переговорных комнат и их
* занятости в модели нет). Базовая раскладка/токены — 1:1 из design/mockups/calendar.html
* (.layout-grid, .week-card, .create-card, .pin-block, .recurrence-panel).
* Тема FullCalendar адаптирована под dayGrid-«чипы» вместо timeGrid-слотов
* (см. комментарий в ConferenceCalendar.tsx).
*/
/* ---------- Грид: сетка вхождений + карточка создания/редактирования ---------- */
.layout-grid {
display: grid;
grid-template-columns: 1fr 400px;
gap: var(--space-6);
align-items: start;
}
/* min-width:0 — без этого грид-элемент с 1fr-треком не сжимается уже своего
min-content (тут это внутренняя таблица FullCalendar с 5 колонками) —
классический grid blowout: трек и вся страница раздуваются шире вьюпорта,
появляется горизонтальный скролл (см. тот же приём в lobby.css/.action). */
.layout-grid > * { min-width: 0; }
.week-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-2xl);
padding: var(--space-6);
}
.week-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-5); flex-wrap: wrap; gap: var(--space-3); }
.date-nav { display: flex; align-items: center; gap: var(--space-3); }
.date-nav-arrow {
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-ink-500);
display: flex;
align-items: center;
justify-content: center;
}
.date-nav-arrow:hover { border-color: var(--color-border-strong); color: var(--color-ink-900); }
.date-nav span { font: var(--text-h3); color: var(--color-ink-900); min-width: 200px; text-align: center; text-transform: capitalize; }
.date-nav .btn-secondary { flex-shrink: 0; white-space: nowrap; }
.view-toggle { display: flex; gap: 4px; background: var(--color-bg-alt); border: 1px solid var(--color-border); border-radius: var(--radius-full); padding: 4px; }
.view-toggle button { border: none; background: transparent; border-radius: var(--radius-full); padding: 7px 16px; font: var(--text-body); font-weight: 600; color: var(--color-ink-500); }
.view-toggle button.is-active { background: var(--color-surface); color: var(--color-ink-700); box-shadow: var(--shadow-sm); }
/* ---------- Тема FullCalendar (dayGrid) ---------- */
.vc-calendar { font-family: var(--font-body); }
.vc-calendar .fc { --fc-border-color: var(--color-border); --fc-page-bg-color: var(--color-surface); --fc-neutral-bg-color: var(--color-bg-alt); --fc-today-bg-color: rgba(212,242,227,.16); }
.vc-calendar .fc-theme-standard td, .vc-calendar .fc-theme-standard th { border-color: var(--color-border); }
.vc-calendar .fc-col-header-cell { background: var(--color-bg-alt); padding: 6px 0; }
.vc-calendar .fc-col-header-cell-cushion { font: var(--text-caption); color: var(--color-ink-500); text-decoration: none; padding: 6px 0; }
.vc-calendar .fc-daygrid-day-frame { min-height: 460px; padding: var(--space-2); }
.vc-calendar .fc-daygrid-day-number { font: var(--text-h3); color: var(--color-ink-900); padding: 4px 6px; }
.vc-calendar .fc-day-today { background: rgba(212,242,227,.16) !important; }
.vc-calendar .fc-day-today .fc-daygrid-day-number { color: var(--color-ink-700); }
.vc-calendar .fc-daygrid-day-events { margin: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.vc-calendar .fc-daygrid-event-harness { margin: 0 !important; }
.vc-calendar .fc-daygrid-more-link { font: var(--text-caption); color: var(--color-ink-500); }
/*
* Событие-вхождение конференции — карточка-«чип» в духе .conf-chip мокапа.
* FullCalendar оборачивает событие в .fc-daygrid-event (наследует классы из
* `classNames`) — переопределяем его внешний вид под чип целиком, без
* отдельного контейнера.
*/
.vc-calendar .fc-daygrid-event.conf-chip {
display: flex !important;
flex-direction: column;
gap: 4px;
background: var(--color-surface) !important;
border: 1px solid var(--color-border) !important;
border-radius: var(--radius-md) !important;
padding: var(--space-3);
white-space: normal;
cursor: pointer;
transition: box-shadow var(--transition-fast), border-color var(--transition-fast);
}
.vc-calendar .fc-daygrid-event.conf-chip:hover { box-shadow: var(--shadow-sm); border-color: var(--color-border-strong); }
.vc-calendar .fc-daygrid-event.conf-chip .fc-event-time,
.vc-calendar .fc-daygrid-event.conf-chip .fc-event-title { display: none; } /* заменено собственным eventContent */
.chip-time { display: block; font: var(--text-caption); color: var(--color-ink-500); text-transform: none; letter-spacing: normal; }
.chip-title { display: flex; align-items: center; gap: 6px; font: var(--text-body); font-weight: 700; color: var(--color-ink-900); }
.chip-lock { width: 13px; height: 13px; flex-shrink: 0; }
.conf-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 9px; border-radius: var(--radius-full); font: var(--text-caption); font-size: 11px; width: fit-content; }
.conf-badge.scheduled { background: var(--color-warning-bg); color: var(--color-warning); }
.conf-badge.pinned { background: var(--color-bg-alt); color: var(--color-ink-700); border: 1px solid var(--color-border-strong); }
.conf-badge.live { background: var(--color-success-bg); color: var(--color-success); }
.conf-badge.ended { background: var(--color-bg-alt); color: var(--color-ink-500); }
.vc-empty-hint { text-align: center; padding: var(--space-8); color: var(--color-ink-500); font: var(--text-body-lg); }
/* ---------- Карточка создания/редактирования конференции ---------- */
.create-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-2xl);
padding: var(--space-6);
position: sticky;
top: 96px;
}
.create-card h2 { font: var(--text-h2); color: var(--color-ink-900); margin: 0 0 var(--space-5); display: flex; align-items: center; gap: 8px; }
.field-row { display: flex; gap: var(--space-3); }
.field-row .field { flex: 1; }
.next-occurrence { display: flex; align-items: center; gap: 8px; background: var(--color-bg-alt); border-radius: var(--radius-md); padding: 10px 14px; font: var(--text-body); color: var(--color-ink-700); font-weight: 600; margin-bottom: var(--space-5); }
/* Тумблер (используется и для «закрытая по паролю», и для «закрепить») */
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-3) 0; border-top: 1px solid var(--color-border); }
.toggle-copy strong { display: block; font: var(--text-body); color: var(--color-ink-900); }
.toggle-copy span { font: 12px var(--font-body); color: var(--color-ink-500); }
.switch { position: relative; width: 44px; height: 24px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.slider { position: absolute; inset: 0; background: var(--color-border-strong); border-radius: var(--radius-full); transition: background var(--transition-fast); cursor: pointer; }
.slider::before { content:""; position:absolute; width:18px; height:18px; left:3px; top:3px; background:#fff; border-radius:50%; transition: transform var(--transition-fast); }
.switch input:checked + .slider { background: var(--color-accent-active); }
.switch input:checked + .slider::before { transform: translateX(20px); }
.switch input:focus-visible + .slider { outline: 2px solid var(--color-focus-ring); outline-offset: 2px; }
/* Disabled-переключатель (напр. анонс записи конференций в «Модулях»
админки) — приглушение вместо hover/checked-цвета. */
.switch input:disabled + .slider { opacity: .5; cursor: not-allowed; }
.password-block { margin-top: var(--space-4); }
/* ---------- Блок повторений (recurrence) ---------- */
.recurrence-panel {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px dashed var(--color-border);
}
.recurrence-option-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border: 1px solid var(--color-border); border-radius: var(--radius-md);
cursor: pointer; font: var(--text-body); font-weight: 600; color: var(--color-ink-900);
transition: border-color var(--transition-fast), background var(--transition-fast);
}
.recurrence-option-row:has(input:checked) { border-color: var(--color-ink-700); background: var(--color-bg-alt); }
.recurrence-option-row input[type="radio"] { width: 16px; height: 16px; accent-color: var(--color-ink-700); flex-shrink: 0; }
.recurrence-sub { padding: var(--space-3) 12px 4px 38px; }
.weekday-toggles { display: flex; gap: 6px; flex-wrap: wrap; }
.weekday-chip {
width: 34px; height: 34px; border-radius: 50%;
border: 1.5px solid var(--color-border); background: var(--color-surface);
font: var(--text-caption); color: var(--color-ink-500);
display: flex; align-items: center; justify-content: center;
cursor: pointer;
}
.weekday-chip.is-selected { background: var(--color-ink-700); border-color: var(--color-ink-700); color: #fff; }
.recurrence-hint { font: 12px var(--font-body); color: var(--color-ink-500); margin-top: 8px; }
/* ---------- Кнопки действий карточки редактирования ---------- */
.btn-row { display: flex; gap: var(--space-3); margin-top: var(--space-5); }
.btn-row .btn { flex: 1; justify-content: center; }
.danger-link-row { text-align: center; margin-top: var(--space-4); }
.btn-text { background: none; border: none; color: var(--color-danger); font: var(--text-body); font-weight: 600; padding: 4px; cursor: pointer; }
.btn-text:hover { text-decoration: underline; }
.btn.full { width: 100%; justify-content: center; margin-top: var(--space-5); }
.btn-danger { background: var(--color-danger); color: #fff; font: var(--text-body); font-weight: 700; padding: 9px 18px; border-radius: var(--radius-full); border: none; cursor: pointer; display: inline-flex; align-items: center; gap: 8px; }
.btn-danger:hover:not(:disabled) { filter: brightness(0.92); }
/* ---------- Пикер участников (ParticipantsPicker) ----------
* Новый элемент без готового макета — собран из утверждённых паттернов:
* чипы как `.chip` фильтров «Моих конференций», выпадающий список результатов
* переиспользует классы дроплиста селекта (`.vc-select-panel`/`.vc-select-option`,
* §4.16), поле поиска — как `.search-wrap` админки.
*/
.participants-picker { position: relative; }
.participants-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: var(--space-3); }
.participant-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px 4px 4px; border-radius: var(--radius-full);
border: 1px solid var(--color-border); background: var(--color-bg-alt);
font: var(--text-body); font-weight: 600; color: var(--color-ink-900);
}
.participant-chip.is-organizer { padding-right: 12px; border-color: var(--color-border-strong); }
.participant-chip-role { font: var(--text-caption); color: var(--color-ink-500); font-weight: 600; }
.participant-chip-remove {
width: 18px; height: 18px; flex-shrink: 0; border-radius: 50%; border: none;
background: transparent; color: var(--color-ink-400);
display: flex; align-items: center; justify-content: center; cursor: pointer;
}
.participant-chip-remove:hover:not(:disabled) { background: var(--color-border); color: var(--color-ink-900); }
.participant-chip-remove:disabled { cursor: not-allowed; opacity: .5; }
.participants-search-wrap { position: relative; }
.participants-search-wrap .icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--color-ink-400); }
.participants-search-wrap input {
width: 100%; font: var(--text-body-lg); color: var(--color-ink-900);
padding: 10px 14px 10px 38px; border-radius: var(--radius-md);
border: 1px solid var(--color-border); background: var(--color-surface); font-family: var(--font-body);
}
.participants-search-wrap input:focus { outline: none; border-color: var(--color-focus-ring); box-shadow: 0 0 0 2px var(--color-focus-ring); }
.participants-search-wrap input:disabled { background: var(--color-bg-alt); color: var(--color-ink-300); cursor: not-allowed; }
.participants-results { top: calc(100% + 6px); max-height: 240px; }
.participant-option-body { display: flex; align-items: center; gap: 8px; font: var(--text-body); color: var(--color-ink-900); }
.participants-results-hint { padding: 10px 14px; font: var(--text-body); color: var(--color-ink-500); }
/* ---------- Диалог быстрого просмотра вхождения (ConferenceOccurrenceDialog) ---------- */
.detail-row { display: flex; align-items: flex-start; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--color-border); font: var(--text-body); color: var(--color-ink-900); }
.detail-row:last-of-type { border-bottom: none; }
.detail-row svg { color: var(--color-ink-400); margin-top: 2px; flex-shrink: 0; }
.detail-row .label { color: var(--color-ink-500); min-width: 90px; flex-shrink: 0; }
.title-lock { width: 15px; height: 15px; margin-right: 6px; flex-shrink: 0; }
/* =====================================================================
* Тёмная тема: точечные патчи (design/mockups/dark/README.md).
* ===================================================================== */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .vc-calendar .fc { --fc-today-bg-color: rgba(127, 221, 168, 0.10); }
:root:not([data-theme]) .vc-calendar .fc-day-today { background: rgba(127, 221, 168, 0.10) !important; }
:root:not([data-theme]) .weekday-chip.is-selected { color: var(--color-accent-text); }
:root:not([data-theme]) .btn-danger { background: var(--color-danger-solid); }
:root:not([data-theme]) .btn-danger:hover:not(:disabled) { background: var(--color-danger-solid-hover); filter: none; }
}
:root[data-theme='dark'] .vc-calendar .fc { --fc-today-bg-color: rgba(127, 221, 168, 0.10); }
:root[data-theme='dark'] .vc-calendar .fc-day-today { background: rgba(127, 221, 168, 0.10) !important; }
:root[data-theme='dark'] .weekday-chip.is-selected { color: var(--color-accent-text); }
:root[data-theme='dark'] .btn-danger { background: var(--color-danger-solid); }
:root[data-theme='dark'] .btn-danger:hover:not(:disabled) { background: var(--color-danger-solid-hover); filter: none; }
/* ---------- Узкие экраны ---------- */
/* ВАЖНО: этот блок должен идти строго ПОСЛЕДНИМ в файле — при равной
специфичности с безусловными правилами выше (`.create-card`, `.field-row`
и т.п. — те же 0,1,0/0,1,1) побеждает порядок в исходнике, а не сам факт
совпадения media query (тот же баг уже ловили в lobby.css/.action, гейт 3
— см. комментарий там). Макет calendar.html — desktop-only
(`html { min-width: 1280px }`, DESIGN_SYSTEM.md §«Breakpoints»), адаптива
в нём нет — раскладка ниже подобрана по здравому смыслу: календарь сверху,
карточка создания/редактирования — под ним (сохраняем порядок вёрстки).
*/
@media (max-width: 960px) {
/* Двухколоночный грид (сетка + сайдбар 400px) → одна колонка, сайдбар
перестаёт быть sticky (в потоке одной колонки залипание при скролле
страницы выглядит и работает хуже, чем простое следование за контентом). */
.layout-grid { grid-template-columns: 1fr; }
.create-card { position: static; top: auto; }
}
@media (max-width: 640px) {
.week-card, .create-card { padding: var(--space-4); }
.week-toolbar { flex-direction: column; align-items: stretch; }
.date-nav { justify-content: space-between; }
.date-nav span { min-width: 0; flex: 1; }
/* Недельная сетка FullCalendar (5 рабочих дней) не помещается на ~360px
содержимого карточки без потери читаемости — даём ей прокручиваться
ГОРИЗОНТАЛЬНО ВНУТРИ карточки (min-width задаёт компактную, но ещё
читаемую ширину колонки), а не раздувать всю страницу (см. min-width:0
на .layout-grid выше — здесь наоборот сознательно возвращаем разумный
min-width самому графику, но уже внутри своего overflow-контейнера). */
.vc-calendar { overflow-x: auto; }
.vc-calendar .fc { min-width: 560px; }
.field-row { flex-wrap: wrap; }
.recurrence-sub { padding-left: var(--space-4); }
}

View File

@@ -0,0 +1,80 @@
/*
* VidConf — стили экрана подключения к конференции.
* 1:1 воспроизводит design/mockups/join.html (3 состояния одного флоу).
* Экран публичный (доступен гостю без аккаунта), поэтому не переиспользует
* `.page-shell`/`ShellTopbar` — свой минимальный топбар с брендом.
*/
.join-shell { min-height: 100vh; display: flex; flex-direction: column; background: var(--color-bg); }
.join-topbar { display: flex; align-items: center; justify-content: space-between; padding: var(--space-5) var(--space-8); }
.join-main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-6) var(--space-8) var(--space-16);
}
.screen-block { width: 100%; max-width: 440px; }
.flow-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-2xl); padding: var(--space-8); }
.flow-icon {
width: 52px; height: 52px;
border-radius: var(--radius-full);
background: var(--color-bg-alt);
color: var(--color-ink-700);
display: flex; align-items: center; justify-content: center;
margin: 0 auto var(--space-5);
}
.flow-icon .icon { width: 24px; height: 24px; }
.flow-card h1 { font: var(--text-h1); color: var(--color-ink-900); margin: 0 0 var(--space-2); text-align: center; }
.flow-card .flow-sub { font: var(--text-body-lg); color: var(--color-ink-500); margin: 0 0 var(--space-6); text-align: center; }
.field-optional { font-weight: 400; color: var(--color-ink-400); }
.input-wrap { position: relative; }
.input-wrap .icon {
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
color: var(--color-ink-400);
width: 20px; height: 20px;
}
.input-wrap input {
width: 100%; font: var(--text-body-lg); color: var(--color-ink-900);
padding: 11px 14px 11px 42px; border-radius: var(--radius-md); border: 1px solid var(--color-border);
background: var(--color-surface); outline: none;
}
.input-wrap input::placeholder { color: var(--color-ink-300); }
.input-wrap input:hover { border-color: var(--color-border-strong); }
.input-wrap input:focus { border-color: var(--color-focus-ring); box-shadow: 0 0 0 3px rgba(59, 77, 149, 0.12); }
.input-wrap .toggle-eye {
left: auto; right: 14px; top: 50%; transform: translateY(-50%);
background: none; border: none; padding: 0; cursor: pointer; color: var(--color-ink-400);
}
.field.has-error input { border-color: var(--color-danger); box-shadow: 0 0 0 3px rgba(199, 54, 79, 0.1); }
.error-msg { display: flex; align-items: center; gap: 6px; margin-top: var(--space-2); font: var(--text-body); color: var(--color-danger); }
.link-back { display: block; text-align: center; margin-top: var(--space-5); font: var(--text-body); font-weight: 600; color: var(--color-ink-500); text-decoration: none; background: none; border: none; width: 100%; cursor: pointer; }
.link-back:hover { color: var(--color-ink-700); }
/* Плашка целевой конференции (состояния «представиться»/«пароль») */
.target-conf { display: flex; align-items: center; gap: 12px; background: var(--color-bg-alt); border-radius: var(--radius-lg); padding: var(--space-4); margin-bottom: var(--space-6); }
.target-conf .icon-wrap {
width: 40px; height: 40px; border-radius: 50%;
background: var(--color-surface); border: 1px solid var(--color-border); color: var(--color-ink-700);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.target-conf .icon-wrap .icon { width: 18px; height: 18px; }
.target-conf strong { display: block; font: var(--text-body); color: var(--color-ink-900); }
.target-conf span { font: var(--text-mono-sm); color: var(--color-ink-500); }
/* =====================================================================
* Тёмная тема: точечные патчи (design/mockups/dark/README.md).
* ===================================================================== */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .input-wrap input:focus { box-shadow: 0 0 0 3px rgba(214, 168, 61, 0.18); }
:root:not([data-theme]) .field.has-error input { box-shadow: 0 0 0 3px rgba(235, 147, 161, 0.18); }
}
:root[data-theme='dark'] .input-wrap input:focus { box-shadow: 0 0 0 3px rgba(214, 168, 61, 0.18); }
:root[data-theme='dark'] .field.has-error input { box-shadow: 0 0 0 3px rgba(235, 147, 161, 0.18); }

View File

@@ -0,0 +1,210 @@
/*
* VidConf — стили экрана лобби.
*
* `.page-shell`/`.page-main`/`.page-head` — общая разметка светлой оболочки,
* переиспользуется и другими страницами (см. CalendarPage.tsx), поэтому эти
* три класса общие и не привязаны к разметке лобби.
*
* Остальное — 1:1 воспроизводит хаб действий design/mockups/lobby.html
* (мозаика из 4 действий + медальон логотипа в центре пересечения).
*/
.page-shell { min-height: 100vh; background: var(--color-bg); }
main.page-main { padding: var(--space-8); max-width: 1440px; margin: 0 auto; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: var(--space-6); }
.page-head h1 { font: var(--text-display-lg); font-family: var(--font-display); color: var(--color-ink-700); margin: 0 0 6px; }
.page-head p { font: var(--text-body-lg); color: var(--color-ink-500); margin: 0; }
/* Общая адаптивная подстройка контейнера/шапки страниц оболочки (Calendar/
MyConferences/Admin) — узкие экраны не должны получать горизонтальную
прокрутку из-за фиксированных отступов space-8. Раскладка контента внутри
каждой страницы (сетки/сайдбары) — зона соответствующих блоков. */
@media (max-width: 640px) {
main.page-main { padding: var(--space-5) var(--space-4); }
.page-head { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
}
/* ---------- Лобби-хаб (мозаика действий) ---------- */
.lobby-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
background:
radial-gradient(1100px 520px at 10% -10%, rgba(59, 77, 149, 0.06), transparent 60%),
radial-gradient(900px 480px at 105% 110%, rgba(212, 242, 227, 0.28), transparent 55%),
var(--color-bg);
}
/* Тёмная тема: декоративные радиальные пятна фона построены на индиго/мятном
светлой темы (литеральные rgba, не var()) — патч в тон новой палитры
(янтарь/мята той же светлоты, что и в комнате конференции). */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .lobby-shell {
background:
radial-gradient(1100px 520px at 10% -10%, rgba(214, 168, 61, 0.05), transparent 60%),
radial-gradient(900px 480px at 105% 110%, rgba(127, 221, 168, 0.14), transparent 55%),
var(--color-bg);
}
}
:root[data-theme='dark'] .lobby-shell {
background:
radial-gradient(1100px 520px at 10% -10%, rgba(214, 168, 61, 0.05), transparent 60%),
radial-gradient(900px 480px at 105% 110%, rgba(127, 221, 168, 0.14), transparent 55%),
var(--color-bg);
}
.hub-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6) var(--space-8) var(--space-12);
}
.hub-wrap { display: flex; flex-direction: column; align-items: center; gap: var(--space-8); width: 100%; }
.hub-intro { text-align: center; max-width: 560px; }
.hub-intro .eyebrow { font: var(--text-caption); color: var(--color-ink-400); margin: 0 0 var(--space-2); }
.hub-intro h1 { font: var(--text-display-lg); font-family: var(--font-display); color: var(--color-ink-700); margin: 0; }
.hub {
position: relative;
width: min(1420px, 100%);
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 340px);
gap: var(--space-8);
}
/* Карточка-действие. Медальон с иконкой «сидит» на верхней кромке. */
.action {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: var(--space-3);
padding: var(--space-10) var(--space-12) var(--space-8);
margin-top: 34px;
/* min-width:0 — иначе grid-элемент с 1fr-треком не сжимается уже своего
min-content (длинные заглавные слова вроде «ПОДКЛЮЧИТЬСЯ» на Unbounded);
без этого узкий вьюпорт даёт classic grid blowout — трек и вся сетка
раздуваются шире контейнера, появляется горизонтальный скролл. */
min-width: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-3xl);
text-decoration: none;
color: var(--color-ink-900);
box-shadow: var(--shadow-sm);
cursor: pointer;
font-family: inherit;
transition:
transform var(--transition-base),
box-shadow var(--transition-base),
border-color var(--transition-base);
}
.action:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); border-color: var(--color-border-strong); }
.action:active { transform: translateY(-1px); }
.action:disabled { cursor: not-allowed; opacity: 0.7; transform: none; }
.action-icon {
position: absolute;
top: -34px;
left: 50%;
transform: translateX(-50%);
width: 68px; height: 68px;
border-radius: 50%;
background: var(--color-surface);
border: var(--border-thick) solid var(--color-ink-700);
color: var(--color-ink-700);
display: flex; align-items: center; justify-content: center;
box-shadow: var(--shadow-md);
}
.action-icon .icon { width: 30px; height: 30px; }
.action-title {
margin: 0;
font: 600 40px/1.2 var(--font-display);
letter-spacing: 0.01em;
color: var(--color-ink-700);
text-transform: uppercase;
overflow-wrap: break-word;
}
.action-object { margin: 0; font: 600 21px/1.35 var(--font-body); color: var(--color-ink-900); overflow-wrap: break-word; }
.action-note { margin: 0; font: 400 17px/1.5 var(--font-body); color: var(--color-ink-500); overflow-wrap: break-word; }
/* Главное действие — CTA дизайн-системы (пастельная мята) */
.action--create { background: var(--color-accent); border-color: transparent; }
.action--create:hover:not(:disabled) { background: var(--color-accent-hover); border-color: transparent; }
.action--create:active:not(:disabled) { background: var(--color-accent-active); }
.action--create .action-title { font: 700 54px/1.1 var(--font-display); }
.action--create .action-title, .action--create .action-object { color: var(--color-accent-text); }
.action--create .action-note { color: var(--color-ink-700); }
.action--create .action-icon { background: var(--color-accent); border-color: var(--color-accent-text); color: var(--color-accent-text); }
/* ---------- Медальон VidConf в центре мозаики ---------- */
.hub-logo {
position: absolute;
top: 373px; left: 50%;
transform: translate(-50%, -50%);
width: 156px; height: 156px;
border-radius: 50%;
background: var(--color-surface);
border: var(--border-thick) solid var(--color-ink-700);
box-shadow: var(--shadow-lg);
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 6px;
z-index: 20;
pointer-events: none;
}
.hub-logo .logo-mark { width: 56px; height: 56px; color: var(--color-ink-700); }
.hub-logo .logo-word { font: 600 16px/1 var(--font-display); color: var(--color-ink-700); }
/* ---------- Появление ---------- */
@media (prefers-reduced-motion: no-preference) {
.action, .hub-logo { animation: vc-hub-rise 480ms cubic-bezier(0.22, 0.9, 0.3, 1) backwards; }
.action--create { animation-delay: 60ms; }
.action--calendar { animation-delay: 140ms; }
.action--join { animation-delay: 220ms; }
.action--mine { animation-delay: 300ms; }
.hub-logo { animation-delay: 420ms; animation-name: vc-hub-rise-logo; }
@keyframes vc-hub-rise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes vc-hub-rise-logo {
from { opacity: 0; transform: translate(-50%, calc(-50% + 14px)); }
to { opacity: 1; transform: translate(-50%, -50%); }
}
}
/* ---------- Узкие экраны ---------- */
/* ВАЖНО: этот блок должен идти строго ПОСЛЕДНИМ в файле. При равной
специфичности селекторов (`.action-title` и т.п. — 0,1,0 что здесь, что в
безусловных правилах выше) порядок в исходнике решает, какое правило
применится, а не сам факт совпадения media query — если этот блок стоит
раньше своих безусловных дублей, он будет молча перебит ими на любой
ширине. Так уже ловили баг (правки гейта, гейт 3): грид расползался на
375px, потому что этот блок раньше стоял сразу после базового `.hub {}`,
до `.action-title`/`.action--create .action-title` и т.п. */
@media (max-width: 960px) {
/* Мозаика 2×2 → одна колонка; фиксированная высота 340px больше не нужна —
тайлы подстраиваются под контент. Медальон-логотип в центре сетки на
узких экранах убираем совсем: он был позиционирован под чёткую 2×2
геометрию (см. .hub-logo выше) и на одну колонку не переносится. */
.hub-wrap { gap: var(--space-6); }
.hub { grid-template-columns: 1fr; grid-template-rows: repeat(4, auto); gap: var(--space-5); }
.hub-logo { display: none; }
.hub-main { padding: var(--space-5) var(--space-4) var(--space-8); }
.action { padding: var(--space-8) var(--space-5) var(--space-6); margin-top: 28px; }
.action-icon { width: 56px; height: 56px; top: -28px; }
.action-icon .icon { width: 26px; height: 26px; }
.action-title { font-size: 28px; }
.action--create .action-title { font-size: 34px; }
.action-object { font-size: 17px; }
.action-note { font-size: 15px; }
}

View File

@@ -0,0 +1,85 @@
/*
* VidConf — стили страницы «Мои конференции».
* 1:1 из design/mockups/my-conferences.html (.conf-grid/.conf-card/.pills-row
* /.code-pill). page-shell/page-main/page-head — общие из lobby.css,
* btn-primary/btn-secondary/modal-overlay — из shell.css.
*/
/* flex-wrap — иначе три чипа фильтра на узких экранах не помещаются в одну
строку и раздувают страницу горизонтально (ряд был жёстко nowrap). */
.filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: var(--space-6); }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px; border-radius: var(--radius-full); border: 1.5px solid var(--color-border); background: var(--color-surface); font: var(--text-body); font-weight: 600; color: var(--color-ink-500); cursor: pointer; }
.chip.is-active { border-color: var(--color-ink-700); color: var(--color-ink-700); background: var(--color-bg-alt); }
.conf-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-5); }
/* min-width:0 — грид-элементы иначе не сжимаются уже своего min-content
(grid blowout), см. тот же приём в calendar.css/.layout-grid. */
.conf-grid > * { min-width: 0; }
.conf-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-2xl); padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-4); transition: box-shadow var(--transition-base), border-color var(--transition-base); }
.conf-card:hover { box-shadow: var(--shadow-md); border-color: var(--color-border-strong); }
.conf-card.is-ended { opacity: .85; }
.conf-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); }
.conf-title-wrap h3 { margin: 0 0 4px; font: var(--text-h3); color: var(--color-ink-900); display: flex; align-items: center; gap: 6px; }
.conf-title-wrap p { margin: 0; font: var(--text-body); color: var(--color-ink-500); }
.conf-badge { display: inline-flex; align-items: center; gap: 5px; padding: 4px 12px; border-radius: var(--radius-full); font: var(--text-caption); white-space: nowrap; }
.conf-badge.pinned { background: var(--color-bg-alt); color: var(--color-ink-700); border: 1px solid var(--color-border-strong); }
.conf-badge.scheduled { background: var(--color-warning-bg); color: var(--color-warning); }
.conf-badge.live { background: var(--color-success-bg); color: var(--color-success); }
.conf-badge.ended { background: var(--color-bg-alt); color: var(--color-ink-500); }
.conf-meta-row { display: flex; align-items: center; gap: var(--space-4); font: var(--text-body); color: var(--color-ink-500); }
.conf-meta-row .meta-item { display: flex; align-items: center; gap: 6px; }
.pills-row { display: flex; gap: var(--space-3); }
.pill-group { flex: 1; min-width: 0; }
.pill-label { display: block; font: var(--text-caption); color: var(--color-ink-400); margin-bottom: 4px; }
.code-pill { display: flex; align-items: center; gap: 6px; background: var(--color-bg-alt); border: 1px solid var(--color-border); border-radius: var(--radius-full); padding: 6px 6px 6px 14px; }
.pill-value { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--text-mono-sm); color: var(--color-ink-700); }
.copy-btn { position: relative; width: 28px; height: 28px; flex-shrink: 0; border-radius: 50%; border: none; background: var(--color-surface); color: var(--color-ink-500); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: color var(--transition-fast), box-shadow var(--transition-fast); }
.copy-btn:hover { color: var(--color-ink-700); box-shadow: var(--shadow-sm); }
.copy-btn .icon-check { color: var(--color-success); }
.copy-toast {
position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%);
background: var(--color-ink-900); color: #fff; font: var(--text-caption); text-transform: none; letter-spacing: normal;
padding: 4px 10px; border-radius: var(--radius-sm); white-space: nowrap;
}
.conf-card-footer { display: flex; gap: var(--space-3); margin-top: auto; }
.conf-card-footer .btn { flex: 1; justify-content: center; }
.icon-btn { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-ink-500); display: flex; align-items: center; justify-content: center; cursor: pointer; }
.icon-btn:hover { border-color: var(--color-border-strong); color: var(--color-ink-900); }
/* ---------- Модалка редактирования (переиспользует ConferenceFormCard) ---------- */
/* .create-card сама по себе — визуальная панель (фон/обводка/радиус/паддинг из
calendar.css); здесь только гасим её sticky-позиционирование календаря и
ограничиваем высоту внутри модалки. */
.modal-form-wrap { width: 100%; max-width: 480px; }
.modal-form-wrap .create-card { position: static; max-height: calc(100vh - var(--space-12)); overflow-y: auto; }
/* Тёмная тема: тултип «Скопировано» — заливка была var(--color-ink-900)
(тёмный индиго в светлой теме → стал бы светло-серым в тёмной, ломая
тёмный тултип поверх тёмной страницы); заменена на буквальный #333333
(= --color-room-surface-raised), текст остаётся var(--color-ink-900)
(теперь светлый) — тултип снова тёмный чип со светлым текстом. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .copy-toast { background: #333333; color: var(--color-ink-900); }
}
:root[data-theme='dark'] .copy-toast { background: #333333; color: var(--color-ink-900); }
/* ---------- Узкие экраны ---------- */
/* ВАЖНО: блок должен идти строго ПОСЛЕДНИМ в файле — при равной
специфичности с `.conf-grid` выше порядок в исходнике решает, какое
правило применится (тот же баг уже ловили в lobby.css/.action, гейт 3).
Макет my-conferences.html — desktop-only, адаптива в нём нет; 2 колонки
карточек на узких экранах схлопываются в 1. */
@media (max-width: 860px) {
.conf-grid { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.conf-card { padding: var(--space-5); }
.pills-row { flex-direction: column; }
}

View File

@@ -0,0 +1,25 @@
/*
* VidConf — страница профиля. Новый экран без готового
* макета в design/mockups/ — собран из утверждённых паттернов оболочки:
* карточка `--radius-2xl`/`--shadow-sm` как у `.conf-card`/`.settings-card`,
* поля форм — `.field` (shell.css), аватар — общий `.avatar` (§1.5).
* page-shell/page-main/page-head — общие из lobby.css.
*/
.profile-card {
max-width: 480px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-sm);
padding: var(--space-8);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
/* Второй блок карточки («Смена пароля») — отступ между карточками профиля. */
.profile-card + .profile-card { margin-top: var(--space-6); }
.profile-card h2 { font: var(--text-h3); color: var(--color-ink-900); margin: 0; }
.profile-avatar-block { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-3); }
.profile-avatar-actions { display: flex; align-items: center; gap: var(--space-3); }

View File

@@ -0,0 +1,482 @@
/*
* VidConf — стили экрана комнаты конференции (тёмная тема, [data-theme="room"]).
* Приближение к design/mockups/room.html.
*
* ВАЖНО (см. также комментарий в RoomPage.tsx): видео-сетка участников
* рендерится компонентами @livekit/components-react (GridLayout/FocusLayout/
* CarouselLayout/ParticipantTile) — их внутренняя разметка не совпадает 1:1 с
* `.tile`/`.tile-preview` из макета. Здесь мы (1) переопределяем CSS-переменные
* темы LiveKit (`--lk-*`) значениями токенов VidConf, чтобы цвета/радиусы
* совпадали с дизайн-системой, и (2) стилизуем только внешние контейнеры,
* которые нам доступны через className. Точная пиксельная подгонка внутренней
* разметку ParticipantTile (позиция имени/иконки микрофона 1:1 как в
* макете) вынесена в отдельную доводку.
*/
[data-theme='room'] { height: 100%; }
.room-shell { display: flex; flex-direction: column; height: 100vh; background: var(--color-room-bg); }
.room-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-6);
background: var(--color-room-surface);
border-bottom: 1px solid var(--color-room-tile-border);
flex-shrink: 0;
}
.room-title-block h1 { font: var(--text-h3); margin: 0 0 2px; color: var(--color-room-text-primary); }
.room-title-block p { font: var(--text-body); margin: 0; color: var(--color-room-text-secondary); display: flex; align-items: center; gap: 8px; }
.rec-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-room-mic-on); box-shadow: 0 0 0 3px rgba(70, 217, 122, 0.25); }
/* Инвайт-чип (номер конференции + копирование ссылки) — см. RoomTopbar.tsx. */
.room-invite-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: var(--radius-full);
border: 1px solid var(--color-room-tile-border);
background: var(--color-room-tile);
color: var(--color-room-text-primary);
font: var(--text-body);
font-weight: 600;
}
.room-invite-chip:hover { background: var(--color-room-tile-hover); }
.room-invite-chip span { font: var(--text-mono-sm); color: var(--color-room-text-secondary); }
.room-main { display: flex; flex: 1; min-height: 0; }
.stage { flex: 1; display: flex; flex-direction: column; padding: var(--space-5) var(--space-6); gap: var(--space-4); min-width: 0; }
/*
* Демонстрация экрана: `object-fit: cover` (дефолт VideoTrack для
* камеры) обрезает края — для текста/таблиц на экране это критично, поэтому
* для screenshare-видео явно переключаем на `contain` (letterbox вместо
* обрезки); фон — чёрный, как у самого видео-элемента библиотеки.
* `data-lk-source` — штатный атрибут @livekit/components-react на <video>.
*/
video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
/*
* `.stage-tiles` — общий className и для FocusLayoutContainer (несколько
* участников), и для GridLayout (один/два участника, см. RoomStage.tsx) —
* тут только то, что нужно ОБОИМ (заполнить оставшуюся высоту `.stage`).
* Раскладку колонок (`grid-template-columns`) переопределяем НИЖЕ отдельно,
* только для варианта с FocusLayoutContainer (`.lk-focus-layout`) — раньше
* это правило висело прямо на `.stage-tiles` и наружу задевало GridLayout:
* при одном участнике его плитка сжималась в узкую 22%-колонку слева, а
* остальная сцена оставалась пустой (баг, поймали на скриншоте).
*/
.stage-tiles {
flex: 1;
min-height: 0;
}
/*
* FocusLayoutContainer рендерит `.lk-focus-layout` (grid, первая колонка —
* узкая, под превью; вторая — широкая, под активного спикера) — сужаем
* первую колонку под наш вкус (дефолтный `1fr` слишком гуляет на широких
* мониторах). Селектор нарочно составной (`.stage-tiles.lk-focus-layout`),
* чтобы НЕ задеть `.lk-grid-layout` (тот же className `.stage-tiles` также
* висит на GridLayout при 12 участниках, см. выше). `!important` не нужен:
* `room.css` импортируется в RoomPage.tsx ПОСЛЕ `@livekit/components-styles`,
* поэтому при равной специфичности наши значения и так побеждают по порядку
* в исходном CSS.
*/
.stage-tiles.lk-focus-layout {
grid-template-columns: minmax(200px, 22%) 1fr;
}
/*
* Колонка миниатюр остальных участников — CarouselLayout без явного
* `orientation` (см. комментарий в RoomStage.tsx): при узкой и высокой форме
* контейнера (десктоп, боковая колонка) сам определяет `vertical` и берёт
* встроенные стили `.lk-carousel[data-lk-orientation="vertical"]` из
* @livekit/components-styles (flex-direction: column, скролл по вертикали,
* тайлы делятся по высоте на --lk-max-visible-tiles). Здесь только
* тонкая настройка скроллбара под тёмную тему комнаты — сами
* flex-direction/overflow не трогаем, чтобы не сломать переключение
* ориентации на мобильной ширине (см. media-запрос ниже).
*/
.lk-carousel::-webkit-scrollbar { width: 6px; height: 6px; }
.lk-carousel::-webkit-scrollbar-thumb { background: var(--color-room-tile-border); border-radius: 3px; }
/*
* Мобильная ширина: @livekit/components-styles сам переключает
* `.lk-focus-layout` на `grid-template-rows: 5fr 1fr` (спикер сверху,
* колонка миниатюр становится узкой горизонтальной полосой снизу,
* `.lk-carousel{order:1}`) — колонка шириной 22% там физически не
* поместится, поэтому здесь возвращаем `grid-template-columns` обратно в
* `1fr` (наше правило выше иначе продолжало бы действовать поверх их
* media-запроса — у обоих селекторов равная специфичность, и то, что
* стоит позже в исходнике, побеждает, а мы просто идём в файле позже).
*/
@media (max-width: 600px) {
.stage-tiles.lk-focus-layout { grid-template-columns: 1fr; }
.stage { padding: var(--space-3) var(--space-4); gap: var(--space-3); }
}
/*
* `.room-single-tile` — сцена мини-плеера (Document PiP, `RoomStage` с
* `variant="pip"`): единственная плитка активного окна на весь контейнер, без
* карусели/грида. Портал рендерит
* `RoomStage` НАПРЯМУЮ в `pipWindow.document.body` — своей `.room-shell`/
* `.room-main` цепочки высот там нет, поэтому `.lk-participant-tile`
* растягиваем явным `flex: 1` (а не полагаемся на %-высоту от родителя, как
* `.stage-tiles` в основном окне) плюс подстраховочный `min-height`, чтобы
* плитка не схлопывалась, если видео ещё не выдало реальные размеры.
*/
.room-single-tile { flex: 1; min-height: 0; display: flex; }
.room-single-tile .lk-participant-tile { flex: 1; min-height: 220px; width: 100%; }
/* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */
.room-toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
background: var(--color-room-surface);
border-top: 1px solid var(--color-room-tile-border);
padding: var(--space-3) var(--space-6);
flex-shrink: 0;
}
.tb-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: transparent;
border: none;
padding: 8px 18px;
border-radius: var(--radius-md);
color: var(--color-room-text-primary);
min-width: 76px;
cursor: pointer;
}
.tb-btn .icon-shell {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 1.5px solid var(--color-room-tile-border);
background: var(--color-room-tile);
color: var(--color-room-text-primary);
}
.tb-btn span.label { font: var(--text-caption); text-transform: none; letter-spacing: normal; color: var(--color-room-text-secondary); font-weight: 500; }
.tb-btn:hover .icon-shell { background: var(--color-room-tile-hover); }
.tb-btn.is-off .icon-shell { background: var(--color-room-mic-off); border-color: var(--color-room-mic-off); color: #3a0f16; }
.tb-btn.is-off span.label { color: var(--color-room-mic-off); font-weight: 700; }
/* Кнопка «Демонстрация» — активное состояние подсвечено зелёным,
как и другие «позитивные» индикаторы комнаты (ср. .rec-dot/--color-room-mic-on). */
.tb-btn.is-sharing .icon-shell { background: var(--color-room-mic-on); border-color: var(--color-room-mic-on); color: #10331f; }
.tb-btn.is-sharing span.label { color: var(--color-room-mic-on); font-weight: 700; }
.tb-btn:disabled { opacity: 0.6; cursor: default; }
.tb-btn.danger .icon-shell { border-color: var(--color-room-danger); color: var(--color-room-danger); background: transparent; }
.tb-btn.danger:hover .icon-shell { background: var(--color-room-danger-bg); color: #fff; border-color: var(--color-room-danger-bg); }
.tb-btn.danger span.label { color: var(--color-room-danger); font-weight: 700; }
/* Кнопка «Чат» — подсветка при открытой панели + бейдж непрочитанных. */
.tb-btn .icon-shell { position: relative; }
.tb-btn.is-panel-open .icon-shell { background: var(--color-room-surface-raised); border-color: var(--color-room-speaker-ring); color: var(--color-room-speaker-ring); }
.badge-count {
position: absolute;
top: -2px;
right: -2px;
background: var(--color-room-mic-on);
color: #0e3320;
font-size: 10px;
font-weight: 700;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
/* ---------- Панель чата (см. design/mockups/room.html, .chat-panel) ---------- */
.chat-panel {
width: 320px;
flex-shrink: 0;
background: var(--color-room-surface-raised);
border-left: 1px solid var(--color-room-tile-border);
display: flex;
flex-direction: column;
box-shadow: var(--shadow-room-panel);
}
.chat-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-room-tile-border);
flex-shrink: 0;
}
.chat-head h2 { font: var(--text-h3); margin: 0; color: var(--color-room-text-primary); }
.chat-head button { background: none; border: none; color: var(--color-room-text-secondary); cursor: pointer; display: flex; }
.chat-head button:hover { color: var(--color-room-text-primary); }
.chat-status-banner {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: var(--space-3) var(--space-5);
background: var(--color-room-danger-bg);
color: #fff;
font: var(--text-body);
flex-shrink: 0;
}
.chat-empty { font: var(--text-body); color: var(--color-room-text-tertiary); margin: auto; text-align: center; }
.chat-messages {
flex: 1;
overflow-y: auto;
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.chat-messages::-webkit-scrollbar { width: 6px; }
.chat-messages::-webkit-scrollbar-thumb { background: var(--color-room-tile-border); border-radius: 3px; }
.msg .msg-meta { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
.msg .msg-author { font: var(--text-body); font-weight: 700; color: var(--color-room-mic-on); }
.msg .msg-guest-badge {
font: var(--text-caption);
color: var(--color-room-text-tertiary);
border: 1px solid var(--color-room-tile-border);
border-radius: var(--radius-sm);
padding: 0 6px;
text-transform: none;
letter-spacing: normal;
}
.msg .msg-time { font: 12px var(--font-body); color: var(--color-room-text-tertiary); }
.msg .msg-bubble {
background: var(--color-room-tile);
border-radius: var(--radius-md);
padding: 10px 14px;
font: var(--text-body);
color: var(--color-room-text-primary);
white-space: pre-wrap;
word-break: break-word;
}
.chat-input-row {
padding: var(--space-4);
border-top: 1px solid var(--color-room-tile-border);
display: flex;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
}
.chat-input-row textarea {
flex: 1;
resize: none;
background: var(--color-room-tile);
border: 1px solid var(--color-room-tile-border);
border-radius: var(--radius-lg);
padding: 10px 16px;
color: var(--color-room-text-primary);
font: var(--text-body-lg);
max-height: 96px;
}
.chat-input-row textarea::placeholder { color: var(--color-room-text-tertiary); }
.chat-input-row textarea:disabled { opacity: 0.6; }
.chat-input-row button {
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--color-room-mic-on);
border: none;
color: #10331f;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
cursor: pointer;
}
.chat-input-row button:disabled { opacity: 0.5; cursor: default; }
@media (max-width: 900px) {
.chat-panel {
position: fixed;
inset: 0;
top: 64px;
bottom: 96px;
width: auto;
z-index: 20;
}
}
/*
* ---------- Диалог «Настройки устройств» ----------
* Нет отдельного макета для диалога в design/mockups/room.html — собран из
* уже утверждённых токенов тёмной темы комнаты (`--color-room-*`), тот же
* структурный паттерн (overlay/panel/head/close/field), что и модалки
* оболочки (`shell.css` .modal-*), но перекрашенный под комнату: `.modal-*`
* оболочки используют `--color-ink-*`, которые не переопределяются внутри
* `[data-theme="room"]`, — переиспользовать их напрямую нельзя, получилась
* бы светлая панель на тёмном фоне комнаты.
*/
.room-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
z-index: 200;
}
.room-modal-panel {
background: var(--color-room-surface-raised);
border: 1px solid var(--color-room-tile-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-room-panel);
padding: var(--space-6);
width: 100%;
max-width: 420px;
max-height: calc(100vh - var(--space-12));
overflow-y: auto;
}
.room-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); margin-bottom: var(--space-5); }
.room-modal-head h2 { font: var(--text-h3); color: var(--color-room-text-primary); margin: 0; }
.room-modal-close {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--color-room-tile-border);
background: transparent;
color: var(--color-room-text-secondary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
cursor: pointer;
}
.room-modal-close:hover { color: var(--color-room-text-primary); border-color: var(--color-room-text-secondary); }
.room-field { margin-bottom: var(--space-5); }
.room-field:last-child { margin-bottom: 0; }
.room-field label { display: block; font: var(--text-body); font-weight: 600; color: var(--color-room-text-primary); margin-bottom: var(--space-2); }
.room-field select {
width: 100%;
font: var(--text-body-lg);
color: var(--color-room-text-primary);
padding: 10px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--color-room-tile-border);
background: var(--color-room-tile);
font-family: var(--font-body);
}
.room-field select:focus { outline: none; border-color: var(--color-room-focus-ring); }
/*
* ---------- Чип «Вы демонстрируете экран» ----------
* Рендерится внутри `RoomParticipantTile` (см. её `TileBody`) — т.е. внутри
* `.lk-participant-tile`, который у @livekit/components-react уже
* `position: relative` (см. её базовые стили), поэтому оверлей позиционируем
* абсолютно прямо относительно плитки, без правок родительских контейнеров.
*/
.stage-sharing-chip {
position: absolute;
top: 12px;
left: 12px;
z-index: 5;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
border-radius: var(--radius-full);
background: rgba(30, 30, 30, 0.85);
border: 1px solid var(--color-room-mic-on);
color: var(--color-room-text-primary);
font: var(--text-body);
box-shadow: var(--shadow-md);
}
.stage-sharing-chip svg { width: 18px; height: 18px; color: var(--color-room-mic-on); flex-shrink: 0; }
.stage-sharing-chip button {
border: none;
border-radius: var(--radius-full);
background: var(--color-room-mic-on);
color: #10331f;
font: var(--text-body);
font-weight: 700;
padding: 4px 12px;
cursor: pointer;
flex-shrink: 0;
}
.stage-sharing-chip button:hover { filter: brightness(0.95); }
/* ---------- Заглушка «конференция в мини-окне» (Document PiP) ---------- */
.room-pip-placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
color: var(--color-room-text-secondary);
font: var(--text-body-lg);
text-align: center;
}
.room-pip-placeholder svg { width: 48px; height: 48px; color: var(--color-room-text-tertiary); }
/* ---------- Аватар вместо иконки-заглушки при выключенной камере ---------- */
.room-tile-avatar {
width: 40%;
height: 40%;
min-width: 32px;
min-height: 32px;
max-width: 96px;
max-height: 96px;
font-size: clamp(12px, 3vw, 28px);
font-weight: 700;
}
.room-loader, .room-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
height: 100vh;
color: var(--color-room-text-secondary);
font: var(--text-body-lg);
background: var(--color-room-bg);
}
.room-error .btn-primary { margin-top: var(--space-2); }
/* ---------- Переопределение темы @livekit/components-react токенами VidConf ---------- */
[data-lk-theme='default'] {
--lk-bg: var(--color-room-bg);
--lk-bg2: var(--color-room-surface);
--lk-bg3: var(--color-room-tile);
--lk-bg4: var(--color-room-tile-hover);
--lk-bg5: var(--color-room-tile-hover);
--lk-fg: var(--color-room-text-primary);
--lk-fg2: var(--color-room-text-primary);
--lk-fg3: var(--color-room-text-secondary);
--lk-fg4: var(--color-room-text-secondary);
--lk-border-color: var(--color-room-tile-border);
--lk-accent-fg: #10331f;
--lk-accent-bg: var(--color-room-speaker-ring);
--lk-danger-fg: #fff;
--lk-danger: var(--color-room-danger-bg);
--lk-success: var(--color-room-mic-on);
--lk-control-fg: var(--color-room-text-primary);
--lk-control-bg: var(--color-room-tile);
--lk-control-hover-bg: var(--color-room-tile-hover);
--lk-font-family: var(--font-body);
--lk-border-radius: var(--radius-lg);
--lk-grid-gap: var(--space-4);
--lk-control-bar-height: 0px;
}

View File

@@ -0,0 +1,126 @@
/*
* VidConf — кастомный селект (DESIGN_SYSTEM.md §4.16).
* Триггер повторяет геометрию инпута (§4.4), дроплист — паттерн `.menu`
* (design/mockups/lobby.html). Все правила на var()-токенах — тёмная тема
* переключается сама через каскад tokens.css, отдельных патчей не требуется
* (см. §4.16 «Обе темы»).
*/
.vc-select {
position: relative;
}
.vc-select-trigger {
width: 100%;
height: 44px;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
font: var(--text-body-lg);
color: var(--color-ink-900);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
font-family: var(--font-body);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.vc-select-trigger:hover:not(:disabled) {
border-color: var(--color-border-strong);
}
.vc-select-trigger:focus-visible {
outline: none;
border-color: var(--color-focus-ring);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.vc-select-trigger[aria-expanded='true'] {
border-color: var(--color-focus-ring);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.vc-select-trigger:disabled {
background: var(--color-bg-alt);
color: var(--color-ink-300);
cursor: not-allowed;
}
.vc-select-trigger.has-error {
border-width: 2px;
border-color: var(--color-danger);
}
.vc-select-value,
.vc-select-placeholder {
flex: 1;
min-width: 0;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vc-select-placeholder {
color: var(--color-ink-300);
}
.vc-select-error-icon {
color: var(--color-danger);
flex-shrink: 0;
}
.vc-select-chevron {
color: var(--color-ink-500);
flex-shrink: 0;
transition: transform var(--transition-fast);
}
.vc-select-chevron.is-open {
transform: rotate(180deg);
}
.vc-select-trigger:disabled .vc-select-chevron {
color: var(--color-ink-300);
}
.vc-select-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
width: 100%;
max-height: 280px;
overflow-y: auto;
margin: 0;
padding: var(--space-2);
list-style: none;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
z-index: 30;
}
.vc-select-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
border-radius: var(--radius-sm);
font: var(--text-body-lg);
font-weight: 400;
color: var(--color-ink-900);
cursor: pointer;
}
.vc-select-option.is-active {
background: var(--color-bg-alt);
color: var(--color-ink-700);
}
.vc-select-option.is-selected {
font-weight: 600;
}
.vc-select-option.is-selected svg {
color: var(--color-ink-700);
flex-shrink: 0;
}
.vc-select-option.is-disabled {
color: var(--color-ink-300);
cursor: not-allowed;
}
.vc-select-option.is-disabled.is-active {
background: transparent;
color: var(--color-ink-300);
}

View File

@@ -0,0 +1,455 @@
/*
* VidConf — общие стили светлой оболочки (auth/lobby/calendar/admin).
* 1:1 воспроизводит классы из design/mockups/{auth,lobby}.html, чтобы
* JSX-разметка страниц могла использовать те же имена классов, что и
* утверждённые макеты. Токены — из tokens.css.
*/
/* ---------- Топбар (лобби/календарь/админка) ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-8);
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.brand-mark {
display: inline-flex;
align-items: center;
gap: 8px;
font: var(--text-h3);
color: var(--color-ink-700);
text-decoration: none;
}
.brand-mark .logo-mark {
width: 22px;
height: 22px;
flex-shrink: 0;
}
.topbar-right {
display: flex;
align-items: center;
gap: var(--space-4);
}
/* ---------- Переключатель темы «солнце/луна» (см. design/mockups/dark/*.html) ---------- */
.theme-toggle {
display: inline-flex;
gap: 2px;
background: var(--color-bg-alt);
padding: 4px;
border-radius: var(--radius-full);
border: 1px solid var(--color-border);
}
.theme-toggle-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--color-ink-500);
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle-btn .icon {
width: 16px;
height: 16px;
}
.theme-toggle-btn:hover {
color: var(--color-ink-900);
}
.theme-toggle-btn.is-active {
background: var(--color-surface);
color: var(--color-ink-700);
box-shadow: var(--shadow-sm);
}
/* ---------- Пилюля пользователя (декоративная, см. lobby.html) ---------- */
.user-pill {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 16px 6px 6px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
font: var(--text-body);
font-weight: 600;
color: var(--color-ink-900);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.user-pill:hover {
border-color: var(--color-border-strong);
box-shadow: var(--shadow-sm);
}
.avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color-ink-700);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font: var(--text-caption);
text-transform: none;
letter-spacing: 0;
flex-shrink: 0;
overflow: hidden;
}
/* Фото аватара (Avatar.tsx) — покрывает заглушку с инициалами целиком. */
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.user-pill .avatar {
width: 30px;
height: 30px;
}
/* ---------- Бургер и скрывающаяся панель навигации (см. lobby.html) ---------- */
.menu-wrap {
position: relative;
}
.burger {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-ink-700);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.burger:hover {
border-color: var(--color-border-strong);
box-shadow: var(--shadow-sm);
}
.burger[aria-expanded='true'] {
border-color: var(--color-ink-700);
}
.menu {
position: absolute;
top: calc(100% + 10px);
right: 0;
min-width: 236px;
padding: var(--space-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
display: none;
z-index: 30;
}
.menu.is-open {
display: block;
}
.menu a,
.menu button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 14px;
border: none;
background: transparent;
border-radius: var(--radius-sm);
font: var(--text-body-lg);
font-weight: 600;
color: var(--color-ink-900);
text-decoration: none;
text-align: left;
cursor: pointer;
}
.menu a:hover,
.menu button:hover {
background: var(--color-bg-alt);
color: var(--color-ink-700);
}
.menu a.is-active {
background: var(--color-bg-alt);
color: var(--color-ink-700);
}
.menu a .icon,
.menu button .icon {
color: var(--color-ink-400);
}
.menu-divider {
height: 1px;
background: var(--color-border);
margin: var(--space-2) 6px;
}
.menu a.danger,
.menu button.danger {
color: var(--color-danger);
}
.menu a.danger .icon,
.menu button.danger .icon {
color: var(--color-danger);
}
/* ---------- Топбар на узких экранах ---------- */
@media (max-width: 640px) {
.topbar {
padding: var(--space-3) var(--space-4);
}
.topbar-right {
gap: var(--space-2);
}
.user-pill {
padding: 6px;
gap: 0;
}
.user-pill-name {
display: none;
}
}
/* ---------- Кнопки (общие для оболочки) ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
cursor: pointer;
font-family: var(--font-body);
white-space: nowrap;
transition: background var(--transition-fast), box-shadow var(--transition-fast);
}
.btn-primary {
background: var(--color-accent);
color: var(--color-accent-text);
font: var(--text-body);
font-weight: 700;
padding: 11px 22px;
border-radius: var(--radius-full);
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-hover);
box-shadow: var(--shadow-md);
}
.btn-primary:active:not(:disabled) {
background: var(--color-accent-active);
}
.btn-primary:disabled {
background: rgba(166, 174, 203, 0.35);
color: var(--color-surface);
cursor: not-allowed;
}
.btn-secondary {
background: transparent;
color: var(--color-ink-700);
border: 1.5px solid var(--color-border-pill);
font: var(--text-body);
font-weight: 600;
padding: 9px 18px;
border-radius: var(--radius-full);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-bg-alt);
}
.btn-secondary:disabled {
border-color: var(--color-border);
color: var(--color-ink-300);
cursor: not-allowed;
}
/* ---------- Бейджи статуса ---------- */
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: var(--radius-full);
font: var(--text-caption);
white-space: nowrap;
}
.badge-free { background: var(--color-success-bg); color: var(--color-success); }
.badge-busy { background: var(--color-danger-bg); color: var(--color-danger); }
.badge-booked { background: var(--color-warning-bg); color: var(--color-warning); }
.badge-locked { background: var(--color-bg-alt); color: var(--color-ink-700); }
/* ---------- Индикатор загрузки (bootstrap-сессия, join и т.п.) ---------- */
.app-loader {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
color: var(--color-ink-500);
font: var(--text-body-lg);
background: var(--color-bg);
}
.spin {
animation: vidconf-spin 0.9s linear infinite;
}
@keyframes vidconf-spin {
to { transform: rotate(360deg); }
}
/* ---------- Тосты ---------- */
.toast-stack {
position: fixed;
right: var(--space-6);
bottom: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-2);
z-index: 100;
}
.toast {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 18px;
border-radius: var(--radius-md);
font: var(--text-body);
font-weight: 600;
box-shadow: var(--shadow-lg);
max-width: 360px;
}
.toast-error { background: var(--color-danger-bg); color: var(--color-danger); }
.toast-success { background: var(--color-success-bg); color: var(--color-success); }
.toast-info { background: var(--color-surface); color: var(--color-ink-900); border: 1px solid var(--color-border); }
/* ---------- Ховер-карточка конференции (ConferenceHoverCard) ----------
* Новый элемент без готового макета в design/mockups/ — собран из уже
* утверждённых паттернов (карточка-поповер `--shadow-lg`, бейдж закреплённости
* с `Repeat` из §4.6). Рендерится порталом в document.body с `position: fixed`
* (см. ConferenceHoverCard.tsx) — `pointer-events: none`, чтобы не перекрывать
* клики по элементу под ней.
*/
.conf-hover-card {
pointer-events: none;
z-index: 250;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: var(--space-4);
font: var(--text-body);
color: var(--color-ink-900);
}
.conf-hover-card-hint { margin: 0; color: var(--color-ink-500); }
.conf-hover-card-title { margin: 0 0 var(--space-2); font: var(--text-h3); color: var(--color-ink-900); }
.conf-hover-card-row { display: flex; align-items: center; gap: 6px; margin: 0 0 6px; color: var(--color-ink-700); }
.conf-hover-card-row:last-of-type { margin-bottom: 0; }
.conf-hover-card-label { display: flex; align-items: center; gap: 5px; font: var(--text-caption); color: var(--color-ink-400); margin-right: 4px; }
.conf-hover-card-participants { margin-top: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--color-border); }
.conf-hover-card-participants ul { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; max-height: 140px; overflow-y: auto; }
.conf-hover-card-participants li { display: flex; align-items: center; gap: 8px; font: var(--text-body); color: var(--color-ink-900); }
.conf-hover-card-tag { font: var(--text-caption); color: var(--color-ink-500); }
/* ---------- Модальные диалоги (светлая оболочка) ---------- */
/* Общая обёртка модалок оболочки (диалог быстрого просмотра вхождения
конференции, редактирование в «Моих конференциях» и т.п.) — центрированная
модалка (--shadow-lg) поверх затемнённого фона. */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(46, 52, 84, 0.4);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
z-index: 200;
}
.modal-panel {
background: var(--color-surface);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-lg);
padding: var(--space-6);
width: 100%;
max-width: 420px;
max-height: calc(100vh - var(--space-12));
overflow-y: auto;
}
.modal-panel.is-wide { max-width: 480px; }
.modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); margin-bottom: var(--space-5); }
.modal-head h2 { font: var(--text-h2); color: var(--color-ink-900); margin: 0; }
.modal-close {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-ink-500);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.modal-close:hover { border-color: var(--color-border-strong); color: var(--color-ink-900); }
.modal-actions { display: flex; gap: var(--space-3); margin-top: var(--space-5); }
.modal-actions .btn { flex: 1; justify-content: center; }
/* ---------- Поля форм в модалках (общие для всех диалогов оболочки) ---------- */
.field { margin-bottom: var(--space-5); }
.field:last-child { margin-bottom: 0; }
.field label { display: flex; align-items: center; gap: 6px; font: var(--text-body); font-weight: 600; color: var(--color-ink-900); margin-bottom: var(--space-2); }
.field input, .field textarea, .field select {
width: 100%;
font: var(--text-body-lg);
color: var(--color-ink-900);
padding: 10px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
font-family: var(--font-body);
}
.field input:focus, .field textarea:focus, .field select:focus {
outline: none;
border-color: var(--color-focus-ring);
box-shadow: 0 0 0 3px rgba(59, 77, 149, 0.12);
}
.field-hint { font: var(--text-body); color: var(--color-ink-500); margin: 4px 0 0; }
.error-banner {
display: flex;
gap: 10px;
align-items: flex-start;
background: var(--color-danger-bg);
border: 1px solid rgba(199, 54, 79, 0.3);
color: var(--color-danger);
border-radius: var(--radius-md);
padding: var(--space-4);
font: var(--text-body);
margin-bottom: var(--space-5);
}
.error-banner strong { display: block; margin-bottom: 2px; }
.error-banner svg { flex-shrink: 0; margin-top: 1px; }
/* =====================================================================
* Тёмная тема: точечные патчи (design/mockups/dark/README.md, «Точечные
* правки поверх скопированного CSS»). Все переменные --color-* / --shadow-*
* уже перекрашиваются сами через каскад tokens.css — здесь только правила,
* где использован литеральный hex/rgba (не var()) и поэтому не могли
* перекраситься автоматически. Оба блока (авто по ОС + явный выбор)
* продублированы намеренно, как в самом tokens.css.
* ===================================================================== */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .avatar { color: var(--color-accent-text); }
:root:not([data-theme]) .btn-primary:disabled { background: rgba(122, 122, 122, 0.3); color: var(--color-ink-300); }
:root:not([data-theme]) .modal-overlay { background: rgba(0, 0, 0, 0.6); }
:root:not([data-theme]) .field input:focus,
:root:not([data-theme]) .field textarea:focus,
:root:not([data-theme]) .field select:focus { box-shadow: 0 0 0 3px rgba(214, 168, 61, 0.18); }
:root:not([data-theme]) .error-banner { border-color: rgba(235, 147, 161, 0.3); }
}
:root[data-theme='dark'] .avatar { color: var(--color-accent-text); }
:root[data-theme='dark'] .btn-primary:disabled { background: rgba(122, 122, 122, 0.3); color: var(--color-ink-300); }
:root[data-theme='dark'] .modal-overlay { background: rgba(0, 0, 0, 0.6); }
:root[data-theme='dark'] .field input:focus,
:root[data-theme='dark'] .field textarea:focus,
:root[data-theme='dark'] .field select:focus { box-shadow: 0 0 0 3px rgba(214, 168, 61, 0.18); }
:root[data-theme='dark'] .error-banner { border-color: rgba(235, 147, 161, 0.3); }

View File

@@ -0,0 +1,315 @@
/*
* VidConf — design tokens
* ВНИМАНИЕ: это копия design/tokens.css (источник истины — design/DESIGN_SYSTEM.md).
* Файл продублирован во frontend/, т.к. Vite не обслуживает статику вне корня
* проекта. При изменении design/tokens.css копию нужно обновить вручную.
* Значения синхронизированы 1:1.
*
* Три темы, независимые механизмы переключения:
* - Оболочка, светлая (дефолт) — :root.
* - Оболочка, тёмная — :root[data-theme="dark"] (переключатель
* пользователя, см. useTheme/ThemeToggle) ИЛИ автоматически по
* @media (prefers-color-scheme: dark), если data-theme не проставлен
* явно (см. блок ниже; design/mockups/dark/README.md)
* — палитра 1:1 из утверждённых тёмных
* макетов оболочки.
* - Комната конференции — [data-theme="room"] (скоуп на контейнер
* экрана конференции; отдельный неймспейс --color-room-*, НЕ переключается
* системной темой и НЕ смешивается с dark-оболочкой — комната всегда
* тёмная независимо от выбора пользователя).
*
* Шрифты самохостятся через npm-пакеты @fontsource (без внешних CDN —
* инвариант приватности), с кириллическим subset для русского интерфейса.
* @font-face объявляют сами пакеты; их CSS импортируется в
* frontend/src/main.tsx ДО ./index.css. Здесь — только имена семейств в
* переменных ниже (--font-display/--font-body/--font-mono), которые ДОЛЖНЫ
* совпадать с family из @fontsource: 'Unbounded' (@fontsource/unbounded),
* 'Inter' (@fontsource/inter — статический, не variable, у которого было бы
* 'Inter Variable'), 'JetBrains Mono' (@fontsource/jetbrains-mono).
*/
:root {
color-scheme: light;
/* ---------- Типографика ---------- */
--font-display: 'Unbounded', 'Arial Rounded MT Bold', -apple-system, 'Segoe UI', sans-serif;
--font-body: 'Inter', -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--font-mono: 'JetBrains Mono', 'SFMono-Regular', Menlo, Consolas, monospace;
--text-display-2xl: 700 64px/1.05 var(--font-display);
--text-display-xl: 700 48px/1.1 var(--font-display);
--text-display-lg: 600 34px/1.2 var(--font-display);
--text-h1: 700 28px/1.25 var(--font-body);
--text-h2: 600 22px/1.3 var(--font-body);
--text-h3: 600 18px/1.35 var(--font-body);
--text-body-lg: 400 16px/1.5 var(--font-body);
--text-body: 400 14px/1.5 var(--font-body);
--text-caption: 600 12px/1.4 var(--font-body);
--text-mono-sm: 500 13px/1.4 var(--font-mono);
/* ---------- Spacing (8pt grid, база 4px) ---------- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--space-20: 80px;
--space-24: 96px;
/* ---------- Радиусы ---------- */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-2xl: 24px;
--radius-3xl: 32px;
--radius-full: 999px;
/* ---------- Границы ---------- */
--border-thin: 1px;
--border-md: 1.5px;
--border-thick: 2px;
--border-speaker: 3px;
/* ---------- Breakpoints (справочно, desktop-first) ---------- */
--bp-desktop: 1440px;
--bp-min: 1280px;
/* ---------- Motion ---------- */
--transition-fast: 120ms ease;
--transition-base: 200ms ease;
/* ---------- Цвета: светлая тема (оболочка) ---------- */
--color-bg: #F1F1F1;
--color-bg-alt: #F7F7F8;
--color-surface: #FFFFFF;
--color-surface-glass: rgba(255, 255, 255, 0.55);
--color-border: #E1E3E9;
--color-border-strong: #C7CCDC;
--color-border-pill: #4A5A9E;
--color-ink-900: #2E3454;
--color-ink-700: #3B4D95;
--color-ink-500: #535F94;
--color-ink-400: #6976AC;
--color-ink-300: #A6AECB;
/* Пастельная мята — нейтральный акцент оболочки (акцент не обязан
быть зелёным). Mapping см. design/DESIGN_SYSTEM.md §1.1/§1.3. */
--color-accent: #D4F2E3;
--color-accent-hover: #C7EBD9;
--color-accent-active: #BAE4CE;
--color-accent-text: #2E3454;
--color-success: #0A7A4C;
--color-success-bg: #E4F7ED;
--color-success-dot: #46916B;
--color-danger: #C7364F;
--color-danger-bg: #FCE7EB;
--color-danger-dot: #C65366;
--color-warning: #7F560D;
--color-warning-bg: #FBF0DC;
--color-warning-dot: #B58A26;
--color-focus-ring: #3B4D95;
/* ---------- Тени (светлая тема) ---------- */
--shadow-sm: 0 1px 2px rgba(46, 52, 84, 0.06);
--shadow-md: 0 4px 16px rgba(46, 52, 84, 0.08);
--shadow-lg: 0 12px 32px rgba(46, 52, 84, 0.12);
--shadow-glass: 0 8px 24px rgba(59, 77, 149, 0.15);
}
/* =====================================================================
* Тёмная тема: оболочка (auth/лобби/календарь/join/«мои конференции»/админка)
* Утверждено 2026-07-18. Палитра — 1:1 копия блока :root из
* design/mockups/dark/*.html (все 6 файлов идентичны), происхождение каждого
* значения задокументировано в design/mockups/dark/README.md и продублировано
* в design/DESIGN_SYSTEM.md §1.2b. Принцип: тёмная оболочка не изобретает
* новых цветов, а переиспользует уже утверждённые --color-room-* в новых
* ролях (см. таблицу происхождения), поэтому тёмное лобби и тёмная комната
* читаются как одна система.
*
* Механизм активации (два независимых триггера, одни и те же переопределения
* --color-* / --shadow-* — те же имена, что в :root светлой темы выше):
* 1) явный выбор пользователя — атрибут data-theme="dark" на <html>/:root;
* 2) дефолт по системной теме — @media (prefers-color-scheme: dark),
* только пока data-theme НЕ проставлен явно (:root:not([data-theme])).
* Явный data-theme="light" отключает автодетект и форсирует светлую
* тему (базовый :root выше) даже при тёмной ОС.
* Оба блока продублированы намеренно (plain CSS, без препроцессора) — при
* правке палитры менять оба синхронно.
* ===================================================================== */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
color-scheme: dark;
--color-bg: #1E1E1E;
--color-bg-alt: #242424;
--color-surface: #2A2A2A;
--color-surface-glass: rgba(51, 51, 51, 0.55);
--color-border: #3F3F3F;
--color-border-strong: #585858;
--color-border-pill: #7FDDA8;
--color-ink-900: #E8E8E8;
--color-ink-700: #7FDDA8;
--color-ink-500: #B0B0B0;
--color-ink-400: #5D9A78;
--color-ink-300: #7A7A7A;
--color-accent: #7FDDA8;
--color-accent-hover: #75CA9A;
--color-accent-active: #6CB78C;
--color-accent-text: #10331F;
--color-success: #7FDDA8;
--color-success-bg: rgba(127, 221, 168, 0.14);
--color-success-dot: #7FDDA8;
--color-danger: #EB93A1;
--color-danger-bg: rgba(235, 147, 161, 0.14);
--color-danger-dot: #EB93A1;
--color-danger-solid: #B85468;
--color-danger-solid-hover: #B9495F;
--color-warning: #D6A83D;
--color-warning-bg: rgba(214, 168, 61, 0.16);
--color-warning-dot: #D6A83D;
--color-focus-ring: #D6A83D;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5);
--shadow-glass: 0 8px 24px rgba(0, 0, 0, 0.45);
}
}
:root[data-theme='dark'] {
color-scheme: dark;
--color-bg: #1E1E1E;
--color-bg-alt: #242424;
--color-surface: #2A2A2A;
--color-surface-glass: rgba(51, 51, 51, 0.55);
--color-border: #3F3F3F;
--color-border-strong: #585858;
--color-border-pill: #7FDDA8;
--color-ink-900: #E8E8E8;
--color-ink-700: #7FDDA8;
--color-ink-500: #B0B0B0;
--color-ink-400: #5D9A78;
--color-ink-300: #7A7A7A;
--color-accent: #7FDDA8;
--color-accent-hover: #75CA9A;
--color-accent-active: #6CB78C;
--color-accent-text: #10331F;
--color-success: #7FDDA8;
--color-success-bg: rgba(127, 221, 168, 0.14);
--color-success-dot: #7FDDA8;
--color-danger: #EB93A1;
--color-danger-bg: rgba(235, 147, 161, 0.14);
--color-danger-dot: #EB93A1;
--color-danger-solid: #B85468;
--color-danger-solid-hover: #B9495F;
--color-warning: #D6A83D;
--color-warning-bg: rgba(214, 168, 61, 0.16);
--color-warning-dot: #D6A83D;
--color-focus-ring: #D6A83D;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5);
--shadow-glass: 0 8px 24px rgba(0, 0, 0, 0.45);
}
/* ---------- Тёмная тема: комната конференции ---------- */
[data-theme='room'] {
color-scheme: dark;
--color-room-bg: #1E1E1E;
--color-room-surface: #2A2A2A;
--color-room-surface-raised: #333333;
--color-room-tile: #363636;
--color-room-tile-border: #3F3F3F;
--color-room-tile-hover: #3D3D3D;
--color-room-text-primary: #E8E8E8;
--color-room-text-secondary: #B0B0B0;
--color-room-text-tertiary: #7A7A7A;
--color-room-mic-on: #7FDDA8;
--color-room-mic-off: #EB93A1;
--color-room-speaker-ring: #D6A83D;
--color-room-danger: #EB93A1;
--color-room-danger-bg: #B85468;
--color-room-danger-bg-hover: #B9495F;
--color-room-focus-ring: #D6A83D;
--shadow-speaker-glow: 0 0 0 3px var(--color-room-speaker-ring), 0 0 24px rgba(214, 168, 61, 0.35);
--shadow-room-panel: 0 8px 24px rgba(0, 0, 0, 0.4);
}
/* ---------- Базовые сбросы, общие для макетов ---------- */
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
font: var(--text-body-lg);
color: var(--color-ink-900);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
[data-theme='room'] body,
[data-theme='room'] {
background: var(--color-room-bg);
color: var(--color-room-text-primary);
}
a { color: inherit; }
button {
font: inherit;
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
[data-theme='room'] :focus-visible {
outline-color: var(--color-room-focus-ring);
}
svg.icon {
width: 20px;
height: 20px;
stroke: currentColor;
stroke-width: 2;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
flex-shrink: 0;
}

View File

@@ -0,0 +1,18 @@
/**
* Ambient-типы для экспериментального Document Picture-in-Picture API
* (Chrome/Edge 116+; MDN). В `lib.dom.d.ts` поставляемого TypeScript их пока
* нет — объявлены здесь.
*/
interface DocumentPictureInPictureOptions {
width?: number
height?: number
}
interface DocumentPictureInPicture extends EventTarget {
requestWindow(options?: DocumentPictureInPictureOptions): Promise<Window>
readonly window: Window | null
}
interface Window {
documentPictureInPicture?: DocumentPictureInPicture
}