Первоначальная версия VidConf
This commit is contained in:
79
frontend/src/App.tsx
Normal file
79
frontend/src/App.tsx
Normal 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
260
frontend/src/api/admin.ts
Normal 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
81
frontend/src/api/auth.ts
Normal 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
143
frontend/src/api/client.ts
Normal 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
|
||||
}
|
||||
237
frontend/src/api/conferences.ts
Normal file
237
frontend/src/api/conferences.ts
Normal 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}`)
|
||||
}
|
||||
26
frontend/src/api/health.ts
Normal file
26
frontend/src/api/health.ts
Normal 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
80
frontend/src/api/users.ts
Normal 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 })
|
||||
}
|
||||
72
frontend/src/auth/AuthProvider.tsx
Normal file
72
frontend/src/auth/AuthProvider.tsx
Normal 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>
|
||||
}
|
||||
18
frontend/src/auth/RequireAdmin.tsx
Normal file
18
frontend/src/auth/RequireAdmin.tsx
Normal 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}</>
|
||||
}
|
||||
27
frontend/src/auth/RequireAuth.tsx
Normal file
27
frontend/src/auth/RequireAuth.tsx
Normal 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}</>
|
||||
}
|
||||
26
frontend/src/auth/authContext.ts
Normal file
26
frontend/src/auth/authContext.ts
Normal 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)
|
||||
41
frontend/src/auth/authStore.ts
Normal file
41
frontend/src/auth/authStore.ts
Normal 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)
|
||||
}
|
||||
9
frontend/src/auth/useAuth.ts
Normal file
9
frontend/src/auth/useAuth.ts
Normal 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
|
||||
}
|
||||
450
frontend/src/components/admin/AdminConferencesTab.tsx
Normal file
450
frontend/src/components/admin/AdminConferencesTab.tsx
Normal 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 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)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
278
frontend/src/components/admin/AdminSettingsTab.tsx
Normal file
278
frontend/src/components/admin/AdminSettingsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
241
frontend/src/components/admin/AdminTeamsTab.tsx
Normal file
241
frontend/src/components/admin/AdminTeamsTab.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
148
frontend/src/components/admin/AdminUserCreateDialog.tsx
Normal file
148
frontend/src/components/admin/AdminUserCreateDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
219
frontend/src/components/admin/AdminUserProfileDialog.tsx
Normal file
219
frontend/src/components/admin/AdminUserProfileDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
233
frontend/src/components/admin/AdminUsersTab.tsx
Normal file
233
frontend/src/components/admin/AdminUsersTab.tsx
Normal 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)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
51
frontend/src/components/auth/AuthLayout.tsx
Normal file
51
frontend/src/components/auth/AuthLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
245
frontend/src/components/calendar/ConferenceCalendar.tsx
Normal file
245
frontend/src/components/calendar/ConferenceCalendar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
529
frontend/src/components/calendar/ConferenceFormCard.tsx
Normal file
529
frontend/src/components/calendar/ConferenceFormCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
184
frontend/src/components/calendar/ParticipantsPicker.tsx
Normal file
184
frontend/src/components/calendar/ParticipantsPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
frontend/src/components/layout/ShellTopbar.tsx
Normal file
117
frontend/src/components/layout/ShellTopbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
frontend/src/components/room/ChatPanel.tsx
Normal file
120
frontend/src/components/room/ChatPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
frontend/src/components/room/DeviceSettingsDialog.tsx
Normal file
110
frontend/src/components/room/DeviceSettingsDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
164
frontend/src/components/room/RoomParticipantTile.tsx
Normal file
164
frontend/src/components/room/RoomParticipantTile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
228
frontend/src/components/room/RoomStage.tsx
Normal file
228
frontend/src/components/room/RoomStage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
215
frontend/src/components/room/RoomToolbar.tsx
Normal file
215
frontend/src/components/room/RoomToolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
frontend/src/components/room/RoomTopbar.tsx
Normal file
62
frontend/src/components/room/RoomTopbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
frontend/src/components/room/stageFocus.ts
Normal file
133
frontend/src/components/room/stageFocus.ts
Normal 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 }
|
||||
}
|
||||
31
frontend/src/components/ui/Avatar.tsx
Normal file
31
frontend/src/components/ui/Avatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
frontend/src/components/ui/ConferenceHoverCard.tsx
Normal file
103
frontend/src/components/ui/ConferenceHoverCard.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
55
frontend/src/components/ui/CopyPill.tsx
Normal file
55
frontend/src/components/ui/CopyPill.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
frontend/src/components/ui/LogoMark.tsx
Normal file
67
frontend/src/components/ui/LogoMark.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
233
frontend/src/components/ui/Select.tsx
Normal file
233
frontend/src/components/ui/Select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
frontend/src/components/ui/ThemeToggle.tsx
Normal file
34
frontend/src/components/ui/ThemeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
frontend/src/components/ui/ToastProvider.tsx
Normal file
63
frontend/src/components/ui/ToastProvider.tsx
Normal 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
|
||||
}
|
||||
58
frontend/src/components/ui/button.tsx
Normal file
58
frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
177
frontend/src/hooks/useChat.ts
Normal file
177
frontend/src/hooks/useChat.ts
Normal 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 }
|
||||
}
|
||||
48
frontend/src/hooks/useFullscreen.ts
Normal file
48
frontend/src/hooks/useFullscreen.ts
Normal 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 }
|
||||
}
|
||||
183
frontend/src/hooks/useRoomPiP.ts
Normal file
183
frontend/src/hooks/useRoomPiP.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
77
frontend/src/hooks/useTheme.ts
Normal file
77
frontend/src/hooks/useTheme.ts
Normal 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
15
frontend/src/index.css
Normal 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;
|
||||
}
|
||||
50
frontend/src/lib/localTime.ts
Normal file
50
frontend/src/lib/localTime.ts
Normal 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}`
|
||||
}
|
||||
81
frontend/src/lib/parseJoinQuery.ts
Normal file
81
frontend/src/lib/parseJoinQuery.ts
Normal 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
|
||||
}
|
||||
17
frontend/src/lib/pluralize.ts
Normal file
17
frontend/src/lib/pluralize.ts
Normal 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 'конференций'
|
||||
}
|
||||
144
frontend/src/lib/recurrenceFormat.ts
Normal file
144
frontend/src/lib/recurrenceFormat.ts
Normal 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)}`
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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
67
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
73
frontend/src/pages/AdminPage.tsx
Normal file
73
frontend/src/pages/AdminPage.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
||||
import { AdminConferencesTab } from '@/components/admin/AdminConferencesTab'
|
||||
import { AdminUsersTab } from '@/components/admin/AdminUsersTab'
|
||||
import { AdminTeamsTab } from '@/components/admin/AdminTeamsTab'
|
||||
import { AdminSettingsTab } from '@/components/admin/AdminSettingsTab'
|
||||
import { getHealth } from '@/api/health'
|
||||
import '@/styles/lobby.css'
|
||||
import '@/styles/calendar.css'
|
||||
import '@/styles/admin.css'
|
||||
|
||||
type AdminTab = 'conferences' | 'users' | 'teams' | 'settings'
|
||||
|
||||
const TABS: { value: AdminTab; label: string }[] = [
|
||||
{ value: 'conferences', label: 'Конференции' },
|
||||
{ value: 'users', label: 'Пользователи' },
|
||||
{ value: 'teams', label: 'Команды' },
|
||||
{ value: 'settings', label: 'Настройки' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Администрирование VidConf: управление конференциями, доступом
|
||||
* пользователей и настройками инстанса. Доступ ограничен ролью `admin`
|
||||
* (route-guard — см. `RequireAdmin` в App.tsx).
|
||||
*
|
||||
* Вкладки «Команды» и «Настройки» отсутствуют в макете
|
||||
* `design/mockups/admin.html` — реализованы по общему паттерну
|
||||
* `section-tab`/`table-card`/`toolbar-row` того же макета и DESIGN_SYSTEM.md.
|
||||
*/
|
||||
export function AdminPage() {
|
||||
const [tab, setTab] = useState<AdminTab>('conferences')
|
||||
// Версия инстанса для футера — поле `version` в `/api/health`;
|
||||
// если его нет в ответе, футер версию просто не показывает
|
||||
// (см. `HealthOut.version`).
|
||||
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, retry: false })
|
||||
|
||||
return (
|
||||
<div className="admin-page page-shell">
|
||||
<ShellTopbar />
|
||||
<main className="page-main">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Администрирование</h1>
|
||||
<p>Управление конференциями, доступом и пользователями VidConf</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-tabs">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
className={`section-tab${tab === t.value ? ' is-active' : ''}`}
|
||||
onClick={() => setTab(t.value)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'conferences' && <AdminConferencesTab />}
|
||||
{tab === 'users' && <AdminUsersTab />}
|
||||
{tab === 'teams' && <AdminTeamsTab />}
|
||||
{tab === 'settings' && <AdminSettingsTab />}
|
||||
|
||||
<footer className="admin-footer">
|
||||
VidConf{health?.version ? ` · v${health.version}` : ''}
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
frontend/src/pages/CalendarPage.tsx
Normal file
117
frontend/src/pages/CalendarPage.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
||||
import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
|
||||
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
|
||||
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
|
||||
import { getCalendarOccurrences, getMyConferences, type ConferenceOut, type OccurrenceOut } from '@/api/conferences'
|
||||
import { pluralizeConferences } from '@/lib/pluralize'
|
||||
import '@/styles/lobby.css'
|
||||
import '@/styles/calendar.css'
|
||||
|
||||
interface VisibleRange {
|
||||
fromIso: string
|
||||
toIso: string
|
||||
}
|
||||
|
||||
/** Стабильный дефолт для пропса `occurrences` `ConferenceCalendar` (пока `useQuery` не загрузил данные) — см. комментарий в ConferenceCalendar.tsx про identity пропсов FullCalendar. */
|
||||
const EMPTY_OCCURRENCES: OccurrenceOut[] = []
|
||||
|
||||
/**
|
||||
* Календарь-менеджер запланированных/закреплённых конференций
|
||||
* (см. design/mockups/calendar.html).
|
||||
*
|
||||
* Редактирование в сайдбаре доступно только для «своих» вхождений — тех,
|
||||
* что присутствуют в `/conferences/my` (закреплённые и предстоящие разовые
|
||||
* владельца; там же полная модель — recurrence/duration, которых нет в
|
||||
* `OccurrenceOut` календарной выборки). Остальные вхождения по клику
|
||||
* открывают лёгкий информационный диалог с входом и ссылкой на «Мои
|
||||
* конференции» — согласованное отклонение от макета, вызванное отсутствием
|
||||
* в контракте эндпоинта получения одной конференции по id.
|
||||
*/
|
||||
export function CalendarPage() {
|
||||
const [view, setView] = useState<CalendarViewMode>('dayGridWeek')
|
||||
const [range, setRange] = useState<VisibleRange | null>(null)
|
||||
const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null)
|
||||
const [occurrenceDialogTarget, setOccurrenceDialogTarget] = useState<OccurrenceOut | null>(null)
|
||||
|
||||
const { data: occurrences, isLoading: occurrencesLoading } = useQuery({
|
||||
queryKey: ['conferences', 'calendar', range?.fromIso, range?.toIso],
|
||||
queryFn: () => getCalendarOccurrences(range!.fromIso, range!.toIso),
|
||||
enabled: !!range,
|
||||
})
|
||||
|
||||
const { data: myConferences } = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences })
|
||||
const myConferenceById = useMemo(() => new Map((myConferences ?? []).map((c) => [c.id, c])), [myConferences])
|
||||
|
||||
const handleOccurrenceClick = useCallback(
|
||||
(occurrence: OccurrenceOut) => {
|
||||
// `/conferences/my` может содержать и конференции, куда пользователь
|
||||
// только приглашён участником (не организатор) — редактирование
|
||||
// открываем только владельцу, иначе — тот же
|
||||
// информационный диалог, что и для чужих вхождений.
|
||||
const owned = myConferenceById.get(occurrence.conference_id)
|
||||
if (owned?.is_owner) {
|
||||
setEditingConference(owned)
|
||||
} else {
|
||||
setOccurrenceDialogTarget(occurrence)
|
||||
}
|
||||
},
|
||||
[myConferenceById],
|
||||
)
|
||||
|
||||
// Бэйл-аут по значению (не по ссылке): FullCalendar вызывает `datesSet`
|
||||
// повторно всякий раз, когда меняется identity его пропсов (см. подробный
|
||||
// комментарий в ConferenceCalendar.tsx). Если диапазон фактически не
|
||||
// изменился, `setRange` возвращает тот же объект — React пропускает
|
||||
// ре-рендер (Object.is-бэйлаут), и цикл не запускается даже если
|
||||
// `datesSet` дёрнется ещё раз вхолостую.
|
||||
const handleRangeChange = useCallback((fromIso: string, toIso: string) => {
|
||||
setRange((prev) => (prev && prev.fromIso === fromIso && prev.toIso === toIso ? prev : { fromIso, toIso }))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="page-shell">
|
||||
<ShellTopbar />
|
||||
<main className="page-main">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Календарь конференций</h1>
|
||||
<p>
|
||||
{occurrences?.length ?? 0} {pluralizeConferences(occurrences?.length ?? 0)} в выбранном периоде — запланируйте
|
||||
разовую встречу или закрепите постоянную конференцию с повторением
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setEditingConference(null)}>
|
||||
<Plus style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
Новая конференция
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="layout-grid">
|
||||
<ConferenceCalendar
|
||||
view={view}
|
||||
onViewChange={setView}
|
||||
occurrences={occurrences ?? EMPTY_OCCURRENCES}
|
||||
isLoading={occurrencesLoading}
|
||||
onRangeChange={handleRangeChange}
|
||||
onOccurrenceClick={handleOccurrenceClick}
|
||||
/>
|
||||
|
||||
<ConferenceFormCard
|
||||
key={editingConference?.id ?? 'create'}
|
||||
conference={editingConference ?? undefined}
|
||||
onSaved={() => setEditingConference(null)}
|
||||
onDeleted={() => setEditingConference(null)}
|
||||
onCancelEdit={() => setEditingConference(null)}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{occurrenceDialogTarget && (
|
||||
<ConferenceOccurrenceDialog occurrence={occurrenceDialogTarget} onClose={() => setOccurrenceDialogTarget(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
414
frontend/src/pages/JoinPage.tsx
Normal file
414
frontend/src/pages/JoinPage.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { AlertCircle, ArrowRight, CheckCircle2, Eye, EyeOff, Hash, Link2, Lock, Mail, User, Video } from 'lucide-react'
|
||||
import {
|
||||
guestJoinConference,
|
||||
joinConference,
|
||||
resolveConference,
|
||||
type ConferenceJoinData,
|
||||
} from '@/api/conferences'
|
||||
import { ApiError, errorDetail } from '@/api/client'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { LogoMark } from '@/components/ui/LogoMark'
|
||||
import { ThemeToggle } from '@/components/ui/ThemeToggle'
|
||||
import { parseJoinQuery } from '@/lib/parseJoinQuery'
|
||||
import '@/styles/join.css'
|
||||
|
||||
type Step = 'input' | 'guest-info' | 'password' | 'connecting' | 'ended'
|
||||
|
||||
interface ResolvedTarget {
|
||||
id: string
|
||||
title: string | null
|
||||
isClosed: boolean
|
||||
requiresPassword: boolean
|
||||
/** Нормализованный запрос пользователя (slug/номер) — используется как параметр `/room/:slug`. */
|
||||
query: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключение к конференции (см. design/mockups/join.html) — три состояния
|
||||
* одного флоу: (а) ввод ссылки/номера, (б) «представиться» для гостя,
|
||||
* (в) пароль закрытой конференции. Экран публичный — доступен без сессии
|
||||
* (гость входит без аккаунта), поэтому не завёрнут в `RequireAuth`.
|
||||
*
|
||||
* Маршруты: `/join` (ручной ввод, опционально `?q=` — например, после
|
||||
* редиректа из RoomPage при потере join-состояния) и `/j/:slug` (прямая
|
||||
* ссылка-приглашение — резолвится автоматически при заходе).
|
||||
*
|
||||
* Авторизованный пользователь пропускает «представиться»: после резолва либо
|
||||
* сразу входит (если конференция открыта), либо сразу видит экран пароля
|
||||
* (если закрыта). Гость всегда проходит «представиться»; если конференция
|
||||
* закрыта, экран пароля показывается вторым шагом, после чего оба поля
|
||||
* (имя/email + пароль) уходят одним запросом `guest-join`.
|
||||
*/
|
||||
export function JoinPage() {
|
||||
const navigate = useNavigate()
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { status } = useAuth()
|
||||
const isAuthenticated = status === 'authenticated'
|
||||
|
||||
const initialQuery = slugParam ?? searchParams.get('q') ?? ''
|
||||
const [query, setQuery] = useState(initialQuery)
|
||||
const [resolving, setResolving] = useState(false)
|
||||
const [resolveError, setResolveError] = useState<string | null>(null)
|
||||
|
||||
const [step, setStep] = useState<Step>('input')
|
||||
const [target, setTarget] = useState<ResolvedTarget | null>(null)
|
||||
|
||||
const [guestName, setGuestName] = useState('')
|
||||
const [guestEmail, setGuestEmail] = useState('')
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [invalidPassword, setInvalidPassword] = useState(false)
|
||||
const [joining, setJoining] = useState(false)
|
||||
|
||||
const autoResolvedRef = useRef(false)
|
||||
|
||||
// Прямая ссылка (/j/:slug) или ?q= из редиректа — резолвим сразу, не
|
||||
// дожидаясь клика по «Продолжить», но только после того как известен
|
||||
// статус сессии (нужен для выбора «гость / авторизованный» флоу).
|
||||
useEffect(() => {
|
||||
if (status === 'loading' || autoResolvedRef.current || !initialQuery) return
|
||||
autoResolvedRef.current = true
|
||||
void handleResolve(initialQuery)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status])
|
||||
|
||||
async function handleResolve(rawQuery: string) {
|
||||
const normalized = parseJoinQuery(rawQuery)
|
||||
if (!normalized) return
|
||||
setResolveError(null)
|
||||
setResolving(true)
|
||||
try {
|
||||
const info = await resolveConference(normalized)
|
||||
// Завершённая конференция (ADR-001) — backend отдаёт минимальный ответ
|
||||
// без is_closed/requires_password: сразу показываем «завершена», не
|
||||
// дожидаясь шага «представиться»/пароль и попытки join (которая всё
|
||||
// равно вернёт 410).
|
||||
if (info.status === 'ended') {
|
||||
setTarget({ id: info.id, title: info.title, isClosed: false, requiresPassword: false, query: normalized })
|
||||
setStep('ended')
|
||||
return
|
||||
}
|
||||
const nextTarget: ResolvedTarget = {
|
||||
id: info.id,
|
||||
title: info.title,
|
||||
isClosed: info.is_closed ?? false,
|
||||
requiresPassword: info.requires_password ?? false,
|
||||
query: normalized,
|
||||
}
|
||||
setTarget(nextTarget)
|
||||
if (nextTarget.requiresPassword) {
|
||||
setStep('password')
|
||||
} else if (isAuthenticated) {
|
||||
setStep('connecting')
|
||||
await performJoin(nextTarget, {})
|
||||
} else {
|
||||
setStep('guest-info')
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
setResolveError('Конференция не найдена — проверьте ссылку или номер')
|
||||
} else {
|
||||
setResolveError('Не удалось найти конференцию. Попробуйте ещё раз')
|
||||
}
|
||||
setStep('input')
|
||||
} finally {
|
||||
setResolving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function performJoin(
|
||||
resolved: ResolvedTarget,
|
||||
opts: { password?: string; guestName?: string; guestEmail?: string },
|
||||
) {
|
||||
setJoining(true)
|
||||
setInvalidPassword(false)
|
||||
try {
|
||||
let data: ConferenceJoinData
|
||||
if (isAuthenticated) {
|
||||
data = await joinConference(resolved.id, opts.password ? { password: opts.password } : undefined)
|
||||
} else {
|
||||
data = await guestJoinConference(resolved.id, {
|
||||
display_name: opts.guestName ?? guestName.trim(),
|
||||
email: (opts.guestEmail ?? guestEmail).trim() || undefined,
|
||||
password: opts.password,
|
||||
})
|
||||
}
|
||||
navigate(`/room/${encodeURIComponent(resolved.query)}`, {
|
||||
replace: true,
|
||||
state: {
|
||||
livekitUrl: data.livekit_url,
|
||||
token: data.token,
|
||||
roomName: data.room_name,
|
||||
conferenceId: data.conference_id,
|
||||
chatEnabled: data.chat_enabled,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 403 && errorDetail(err) === 'password_required') {
|
||||
setStep('password')
|
||||
} else if (err instanceof ApiError && err.status === 403 && errorDetail(err) === 'invalid_password') {
|
||||
setInvalidPassword(true)
|
||||
setStep('password')
|
||||
} else if (err instanceof ApiError && err.status === 410) {
|
||||
setStep('ended')
|
||||
} else {
|
||||
setResolveError('Не удалось подключиться к конференции. Попробуйте ещё раз позже')
|
||||
setStep('input')
|
||||
}
|
||||
} finally {
|
||||
setJoining(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
void handleResolve(query)
|
||||
}
|
||||
|
||||
function handleGuestInfoSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!target || !guestName.trim()) return
|
||||
if (target.requiresPassword) {
|
||||
setStep('password')
|
||||
} else {
|
||||
void performJoin(target, {})
|
||||
}
|
||||
}
|
||||
|
||||
function handlePasswordSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!target || !password) return
|
||||
void performJoin(target, { password })
|
||||
}
|
||||
|
||||
function resetToInput() {
|
||||
setResolveError(null)
|
||||
setInvalidPassword(false)
|
||||
setStep('input')
|
||||
}
|
||||
|
||||
if (step === 'connecting') {
|
||||
return (
|
||||
<div className="app-loader" role="status" aria-live="polite">
|
||||
Подключаемся к конференции…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="join-shell">
|
||||
<header className="join-topbar">
|
||||
<Link to="/lobby" className="brand-mark">
|
||||
<LogoMark /> VidConf
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
<main className="join-main">
|
||||
{step === 'input' && (
|
||||
<section className="screen-block">
|
||||
<div className="flow-card">
|
||||
<div className="flow-icon" aria-hidden="true">
|
||||
<Link2 className="icon" aria-hidden="true" />
|
||||
</div>
|
||||
<h1>Подключиться к конференции</h1>
|
||||
<p className="flow-sub">Вставьте ссылку-приглашение или введите номер конференции</p>
|
||||
|
||||
<form onSubmit={handleInputSubmit}>
|
||||
<div className={`field${resolveError ? ' has-error' : ''}`}>
|
||||
<label htmlFor="join-input">Ссылка или номер</label>
|
||||
<div className="input-wrap">
|
||||
<Hash className="icon" aria-hidden="true" />
|
||||
<input
|
||||
id="join-input"
|
||||
type="text"
|
||||
placeholder="vidconf.ru/j/... или 8842 104"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{resolveError ? (
|
||||
<p className="error-msg">
|
||||
<AlertCircle style={{ width: 16, height: 16 }} aria-hidden="true" /> {resolveError}
|
||||
</p>
|
||||
) : (
|
||||
<p className="field-hint">Найдите номер или ссылку в приглашении организатора</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={!query.trim() || resolving}>
|
||||
{resolving ? 'Ищем конференцию…' : 'Продолжить'}
|
||||
{!resolving && <ArrowRight style={{ width: 18, height: 18 }} aria-hidden="true" />}
|
||||
</button>
|
||||
</form>
|
||||
<Link to="/lobby" className="link-back">
|
||||
Назад в лобби
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{step === 'guest-info' && target && (
|
||||
<section className="screen-block">
|
||||
<div className="flow-card">
|
||||
<div className="target-conf">
|
||||
<span className="icon-wrap" aria-hidden="true">
|
||||
{target.isClosed ? <Lock className="icon" aria-hidden="true" /> : <Video className="icon" aria-hidden="true" />}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{target.title ?? 'Конференция без названия'}</strong>
|
||||
<span>№ {target.query}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flow-icon" aria-hidden="true">
|
||||
<User className="icon" aria-hidden="true" />
|
||||
</div>
|
||||
<h1>Как вас зовут?</h1>
|
||||
<p className="flow-sub">У вас нет аккаунта VidConf — представьтесь, чтобы войти как гость</p>
|
||||
|
||||
<form onSubmit={handleGuestInfoSubmit}>
|
||||
<div className="field">
|
||||
<label htmlFor="guest-name">
|
||||
Имя<span className="field-optional">· обязательно</span>
|
||||
</label>
|
||||
<div className="input-wrap">
|
||||
<User className="icon" aria-hidden="true" />
|
||||
<input
|
||||
id="guest-name"
|
||||
type="text"
|
||||
placeholder="Как к вам обращаться"
|
||||
value={guestName}
|
||||
onChange={(e) => setGuestName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="guest-email">
|
||||
Email<span className="field-optional">· необязательно</span>
|
||||
</label>
|
||||
<div className="input-wrap">
|
||||
<Mail className="icon" aria-hidden="true" />
|
||||
<input
|
||||
id="guest-email"
|
||||
type="email"
|
||||
placeholder="you@mail.ru"
|
||||
value={guestEmail}
|
||||
onChange={(e) => setGuestEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="field-hint">Укажите, если хотите получить саммари конференции на почту</p>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={!guestName.trim() || joining}>
|
||||
{joining ? 'Входим…' : 'Войти в конференцию'}
|
||||
{!joining && <ArrowRight style={{ width: 18, height: 18 }} aria-hidden="true" />}
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" className="link-back" onClick={resetToInput}>
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{step === 'password' && target && (
|
||||
<section className="screen-block">
|
||||
<div className="flow-card">
|
||||
<div className="target-conf">
|
||||
<span className="icon-wrap" aria-hidden="true">
|
||||
<Lock className="icon" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{target.title ?? 'Конференция без названия'}</strong>
|
||||
<span>№ {target.query} · закрытая</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flow-icon" aria-hidden="true">
|
||||
<Lock className="icon" aria-hidden="true" />
|
||||
</div>
|
||||
<h1>Введите пароль</h1>
|
||||
<p className="flow-sub">Эта конференция закрыта — доступ только по паролю от организатора</p>
|
||||
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<div className={`field${invalidPassword ? ' has-error' : ''}`}>
|
||||
<label htmlFor="join-pass">Пароль конференции</label>
|
||||
<div className="input-wrap">
|
||||
<Lock className="icon" aria-hidden="true" />
|
||||
<input
|
||||
id="join-pass"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
setInvalidPassword(false)
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="toggle-eye"
|
||||
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="icon" aria-hidden="true" /> : <Eye className="icon" aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
{invalidPassword && (
|
||||
<p className="error-msg">
|
||||
<AlertCircle style={{ width: 16, height: 16 }} aria-hidden="true" /> Неверный пароль, попробуйте ещё раз
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={!password || joining}>
|
||||
{joining ? 'Входим…' : 'Войти'}
|
||||
{!joining && <ArrowRight style={{ width: 18, height: 18 }} aria-hidden="true" />}
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" className="link-back" onClick={resetToInput}>
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{step === 'ended' && (
|
||||
<section className="screen-block">
|
||||
<div className="flow-card">
|
||||
{target && (
|
||||
<div className="target-conf">
|
||||
<span className="icon-wrap" aria-hidden="true">
|
||||
<CheckCircle2 className="icon" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{target.title ?? 'Конференция без названия'}</strong>
|
||||
<span>№ {target.query} · завершена</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flow-icon" aria-hidden="true">
|
||||
<CheckCircle2 className="icon" aria-hidden="true" />
|
||||
</div>
|
||||
<h1>Конференция завершена</h1>
|
||||
<p className="flow-sub">Эта конференция уже завершилась и недоступна для повторного входа</p>
|
||||
|
||||
<button type="button" className="btn btn-primary" onClick={resetToInput}>
|
||||
Ввести другую ссылку или номер
|
||||
</button>
|
||||
<Link to="/lobby" className="link-back">
|
||||
Назад в лобби
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
frontend/src/pages/LobbyPage.tsx
Normal file
118
frontend/src/pages/LobbyPage.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Calendar, Link2, User, Video } from 'lucide-react'
|
||||
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
||||
import { LogoMedallion } from '@/components/ui/LogoMark'
|
||||
import { createConference } from '@/api/conferences'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
import '@/styles/lobby.css'
|
||||
|
||||
/**
|
||||
* Лобби — «хаб действий» (см. design/mockups/lobby.html).
|
||||
*
|
||||
* Предустановленных переговорных комнат больше нет: конференции создаются
|
||||
* динамически. Мозаика из 4 плиток — единственный вход в остальные сценарии:
|
||||
* мгновенное создание, календарь плановых конференций, вход по ссылке/номеру,
|
||||
* «Мои конференции».
|
||||
*
|
||||
* Отклонение от макета: топбар здесь — переиспользуемый `ShellTopbar` (прямые
|
||||
* ссылки Лобби/Календарь/Админка + пилюля пользователя), а не бургер-меню из
|
||||
* lobby.html. `ShellTopbar` — общий компонент светлой оболочки (используется
|
||||
* также в календаре/админке), поэтому бургер-меню из макета здесь
|
||||
* сознательно не воспроизводится.
|
||||
*/
|
||||
export function LobbyPage() {
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
async function handleCreate() {
|
||||
if (creating) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const conference = await createConference({})
|
||||
if (!conference.join) {
|
||||
toast.show('Не удалось создать конференцию. Попробуйте ещё раз', 'error')
|
||||
return
|
||||
}
|
||||
navigate(`/room/${conference.slug}`, {
|
||||
state: {
|
||||
livekitUrl: conference.join.livekit_url,
|
||||
token: conference.join.token,
|
||||
roomName: conference.join.room_name,
|
||||
conferenceId: conference.join.conference_id,
|
||||
chatEnabled: conference.join.chat_enabled,
|
||||
number: conference.number,
|
||||
slug: conference.slug,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
toast.show('Не удалось создать конференцию. Попробуйте ещё раз', 'error')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lobby-shell">
|
||||
<ShellTopbar />
|
||||
<main className="hub-main">
|
||||
<div className="hub-wrap">
|
||||
<div className="hub-intro">
|
||||
<p className="eyebrow">[ ДОБРО ПОЖАЛОВАТЬ В VIDCONF ]</p>
|
||||
<h1>Что делаем сегодня?</h1>
|
||||
</div>
|
||||
|
||||
<div className="hub">
|
||||
<button
|
||||
type="button"
|
||||
className="action action--create"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
aria-busy={creating}
|
||||
>
|
||||
<span className="action-icon" aria-hidden="true">
|
||||
<Video className="icon" aria-hidden="true" />
|
||||
</span>
|
||||
<h2 className="action-title">Создать</h2>
|
||||
<p className="action-object">конференцию</p>
|
||||
<p className="action-note">{creating ? 'Создаём…' : 'начнётся сразу — вы первый участник'}</p>
|
||||
</button>
|
||||
|
||||
<button type="button" className="action action--calendar" onClick={() => navigate('/calendar')}>
|
||||
<span className="action-icon" aria-hidden="true">
|
||||
<Calendar className="icon" aria-hidden="true" />
|
||||
</span>
|
||||
<h2 className="action-title">Календарь</h2>
|
||||
<p className="action-object">конференций</p>
|
||||
<p className="action-note">запланировать · закрепить · повторять</p>
|
||||
</button>
|
||||
|
||||
<button type="button" className="action action--join" onClick={() => navigate('/join')}>
|
||||
<span className="action-icon" aria-hidden="true">
|
||||
<Link2 className="icon" aria-hidden="true" />
|
||||
</span>
|
||||
<h2 className="action-title">Подключиться</h2>
|
||||
<p className="action-object">к конференции</p>
|
||||
<p className="action-note">по ссылке или номеру</p>
|
||||
</button>
|
||||
|
||||
<button type="button" className="action action--mine" onClick={() => navigate('/my-conferences')}>
|
||||
<span className="action-icon" aria-hidden="true">
|
||||
<User className="icon" aria-hidden="true" />
|
||||
</span>
|
||||
<h2 className="action-title">Мои</h2>
|
||||
<p className="action-object">конференции</p>
|
||||
<p className="action-note">закреплённые и предстоящие</p>
|
||||
</button>
|
||||
|
||||
<div className="hub-logo" aria-hidden="true">
|
||||
<LogoMedallion className="logo-mark" />
|
||||
<span className="logo-word">VidConf</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
frontend/src/pages/LoginPage.tsx
Normal file
122
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { AlertCircle, ArrowRight, Eye, EyeOff, Lock, Mail } from 'lucide-react'
|
||||
import { AuthLayout } from '@/components/auth/AuthLayout'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { ApiError } from '@/api/client'
|
||||
|
||||
/** Экран входа (см. design/mockups/auth.html, блок «вход в аккаунт»). */
|
||||
export function LoginPage() {
|
||||
const { login, status } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
|
||||
// Уже вошли — незачем показывать форму логина повторно.
|
||||
if (status === 'authenticated') {
|
||||
return <Navigate to="/lobby" replace />
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
setPending(true)
|
||||
try {
|
||||
await login(email, password)
|
||||
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/lobby'
|
||||
navigate(from, { replace: true })
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 403) {
|
||||
setError('Подтвердите почту — мы отправили письмо со ссылкой при регистрации')
|
||||
} else if (err instanceof ApiError && (err.status === 401 || err.status === 400)) {
|
||||
setError('Неверный email или пароль')
|
||||
} else {
|
||||
setError('Не удалось войти. Попробуйте ещё раз позже')
|
||||
}
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<section className="screen-block">
|
||||
<div className="tabs" role="tablist" aria-label="Способ авторизации">
|
||||
<span className="tab is-active" role="tab" aria-selected="true">
|
||||
Вход
|
||||
</span>
|
||||
<Link to="/register" className="tab" role="tab" aria-selected="false">
|
||||
Регистрация
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<div className="field">
|
||||
<label htmlFor="login-email">Рабочая почта</label>
|
||||
<div className="input-wrap">
|
||||
<Mail className="lucide field-icon" aria-hidden="true" />
|
||||
<input
|
||||
id="login-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="you@company.ru"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`field${error ? ' has-error' : ''}`}>
|
||||
<label htmlFor="login-pass">Пароль</label>
|
||||
<div className="input-wrap">
|
||||
<Lock className="lucide field-icon" aria-hidden="true" />
|
||||
<input
|
||||
id="login-pass"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="toggle-eye"
|
||||
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="lucide" /> : <Eye className="lucide" />}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="error-msg">
|
||||
<AlertCircle className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="row-between">
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" defaultChecked /> Запомнить меня
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary btn-block btn-lg" disabled={pending}>
|
||||
{pending ? 'Входим…' : (
|
||||
<>
|
||||
Войти <ArrowRight className="lucide" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
232
frontend/src/pages/MyConferencesPage.tsx
Normal file
232
frontend/src/pages/MyConferencesPage.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangle, CheckCircle2, Clock, Lock, Pencil, Play, Repeat } from 'lucide-react'
|
||||
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
||||
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
|
||||
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
|
||||
import { CopyPill } from '@/components/ui/CopyPill'
|
||||
import { getMyConferences, type ConferenceOut } from '@/api/conferences'
|
||||
import { formatNextOccurrence } from '@/lib/localTime'
|
||||
import { addMinutesToTime, formatRecurrenceSummary } from '@/lib/recurrenceFormat'
|
||||
import '@/styles/lobby.css'
|
||||
import '@/styles/my-conferences.css'
|
||||
|
||||
type Filter = 'all' | 'pinned' | 'upcoming'
|
||||
|
||||
/** До начала разовой конференции можно войти заранее — порог «скоро начнётся» (UX-эвристика, не ограничение backend). */
|
||||
const EARLY_JOIN_WINDOW_MS = 10 * 60 * 1000
|
||||
|
||||
function formatRelativeStart(iso: string): string {
|
||||
const diffMs = new Date(iso).getTime() - Date.now()
|
||||
const diffMin = Math.round(diffMs / 60_000)
|
||||
if (diffMin <= 0) return 'вот-вот'
|
||||
if (diffMin < 60) return `через ${diffMin} мин`
|
||||
const diffHours = Math.round(diffMin / 60)
|
||||
if (diffHours < 24) return `через ${diffHours} ч`
|
||||
return `через ${Math.round(diffHours / 24)} дн.`
|
||||
}
|
||||
|
||||
function formatSubtitle(conf: ConferenceOut): string {
|
||||
if (conf.is_pinned && conf.recurrence) return formatRecurrenceSummary(conf.recurrence)
|
||||
if (!conf.scheduled_at) return 'Разовая конференция'
|
||||
const start = new Date(conf.scheduled_at)
|
||||
const dateLabel = start.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })
|
||||
const timeLabel = start.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||
const endLabel = conf.duration_minutes ? addMinutesToTime(timeLabel, conf.duration_minutes) : null
|
||||
return `Разовая · ${dateLabel}, ${timeLabel}${endLabel ? `–${endLabel}` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* «Мои конференции» (design/mockups/my-conferences.html):
|
||||
* закреплённые (повторяющиеся) и предстоящие разовые конференции, где текущий
|
||||
* пользователь — организатор или приглашённый участник (решение от 2026-07-20,
|
||||
* см. ADR-003); кнопки владельца скрываются по `is_owner`.
|
||||
* Кнопка редактирования показывается только организатору (`is_owner`);
|
||||
* состав участников — в ховер-карточке (ленивый запрос по наведению/фокусу).
|
||||
*
|
||||
* Контракт `/conferences/my` не возвращает завершённые конференции — фильтр
|
||||
* «Завершённые» и карточка «Открыть саммари» из макета опущены.
|
||||
*/
|
||||
export function MyConferencesPage() {
|
||||
const navigate = useNavigate()
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null)
|
||||
const [hoverTarget, setHoverTarget] = useState<{ conferenceId: string; anchorEl: HTMLElement } | null>(null)
|
||||
|
||||
const {
|
||||
data: conferences,
|
||||
isPending,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences })
|
||||
const list = useMemo(() => conferences ?? [], [conferences])
|
||||
|
||||
const pinnedCount = list.filter((c) => c.is_pinned).length
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === 'pinned') return list.filter((c) => c.is_pinned)
|
||||
if (filter === 'upcoming') return list.filter((c) => !c.is_pinned)
|
||||
return list
|
||||
}, [list, filter])
|
||||
|
||||
function handleJoin(conf: ConferenceOut) {
|
||||
navigate(`/j/${conf.slug}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-shell">
|
||||
<ShellTopbar />
|
||||
<main className="page-main">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Мои конференции</h1>
|
||||
<p>
|
||||
{list.length} {list.length === 1 ? 'конференция' : 'конференций'} · {pinnedCount} закреплённых с повторением
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={() => navigate('/calendar')}>
|
||||
Запланировать новую
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="filters">
|
||||
<button type="button" className={`chip${filter === 'all' ? ' is-active' : ''}`} onClick={() => setFilter('all')}>
|
||||
Все
|
||||
</button>
|
||||
<button type="button" className={`chip${filter === 'pinned' ? ' is-active' : ''}`} onClick={() => setFilter('pinned')}>
|
||||
<Repeat style={{ width: 14, height: 14 }} aria-hidden="true" />
|
||||
Закреплённые
|
||||
</button>
|
||||
<button type="button" className={`chip${filter === 'upcoming' ? ' is-active' : ''}`} onClick={() => setFilter('upcoming')}>
|
||||
<Clock style={{ width: 14, height: 14 }} aria-hidden="true" />
|
||||
Предстоящие
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
<p className="field-hint">Загрузка конференций…</p>
|
||||
) : isError ? (
|
||||
<div className="error-banner">
|
||||
<AlertTriangle style={{ width: 18, height: 18 }} aria-hidden="true" />
|
||||
<div style={{ flex: 1 }}>Не удалось загрузить конференции. Проверьте соединение и попробуйте ещё раз.</div>
|
||||
<button type="button" className="btn btn-secondary" style={{ width: 'auto' }} onClick={() => refetch()}>
|
||||
Повторить
|
||||
</button>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="field-hint">Здесь пока пусто — запланируйте конференцию в календаре.</p>
|
||||
) : (
|
||||
<section className="conf-grid">
|
||||
{filtered.map((conf) => {
|
||||
const joinable =
|
||||
conf.is_pinned || conf.status === 'active' || (conf.scheduled_at
|
||||
? new Date(conf.scheduled_at).getTime() - new Date().getTime() <= EARLY_JOIN_WINDOW_MS
|
||||
: false)
|
||||
const link = `${window.location.origin}/j/${conf.slug}`
|
||||
return (
|
||||
<article
|
||||
className={`conf-card${conf.status === 'ended' ? ' is-ended' : ''}`}
|
||||
key={conf.id}
|
||||
onMouseEnter={(e) => setHoverTarget({ conferenceId: conf.id, anchorEl: e.currentTarget })}
|
||||
onMouseLeave={() => setHoverTarget((prev) => (prev?.conferenceId === conf.id ? null : prev))}
|
||||
onFocus={(e) => setHoverTarget({ conferenceId: conf.id, anchorEl: e.currentTarget })}
|
||||
onBlur={() => setHoverTarget((prev) => (prev?.conferenceId === conf.id ? null : prev))}
|
||||
>
|
||||
<div className="conf-card-head">
|
||||
<div className="conf-title-wrap">
|
||||
<h3>
|
||||
{conf.is_closed && <Lock style={{ width: 15, height: 15, flexShrink: 0 }} aria-hidden="true" />}
|
||||
{conf.title ?? 'Конференция без названия'}
|
||||
</h3>
|
||||
<p>{formatSubtitle(conf)}</p>
|
||||
</div>
|
||||
{conf.is_pinned ? (
|
||||
<span className="conf-badge pinned">
|
||||
<Repeat style={{ width: 12, height: 12 }} aria-hidden="true" />
|
||||
Закреплена
|
||||
</span>
|
||||
) : conf.status === 'active' ? (
|
||||
<span className="conf-badge live">
|
||||
<Play style={{ width: 12, height: 12 }} aria-hidden="true" />
|
||||
Идёт сейчас
|
||||
</span>
|
||||
) : conf.status === 'ended' ? (
|
||||
<span className="conf-badge ended">
|
||||
<CheckCircle2 style={{ width: 12, height: 12 }} aria-hidden="true" />
|
||||
Завершена
|
||||
</span>
|
||||
) : (
|
||||
<span className="conf-badge scheduled">
|
||||
<Clock style={{ width: 12, height: 12 }} aria-hidden="true" />
|
||||
Предстоящая
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{conf.is_pinned && conf.next_occurrence && (
|
||||
<div className="conf-meta-row">
|
||||
<span className="meta-item">
|
||||
<Clock style={{ width: 15, height: 15 }} aria-hidden="true" />
|
||||
Ближайшее: {formatNextOccurrence(conf.next_occurrence)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conf.status !== 'ended' && (
|
||||
<div className="pills-row">
|
||||
<CopyPill label="Ссылка" value={link.replace(/^https?:\/\//, '')} copyText={link} />
|
||||
<CopyPill label="Номер" value={conf.number} copyText={conf.number} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="conf-card-footer">
|
||||
{conf.status === 'ended' ? (
|
||||
<button type="button" className="btn btn-secondary" style={{ flex: 1 }} disabled>
|
||||
Конференция завершена
|
||||
</button>
|
||||
) : joinable ? (
|
||||
<button type="button" className="btn btn-primary" onClick={() => handleJoin(conf)}>
|
||||
{conf.is_closed ? (
|
||||
<Lock style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
) : (
|
||||
<Play style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
)}
|
||||
{conf.is_closed ? 'Войти по паролю' : 'Войти'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-secondary" style={{ flex: 1 }} disabled>
|
||||
<Clock style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
Начнётся {formatRelativeStart(conf.scheduled_at!)}
|
||||
</button>
|
||||
)}
|
||||
{conf.status !== 'ended' && conf.is_owner && (
|
||||
<button type="button" className="icon-btn" aria-label="Редактировать" onClick={() => setEditingConference(conf)}>
|
||||
<Pencil style={{ width: 17, height: 17 }} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{editingConference && (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={() => setEditingConference(null)}>
|
||||
<div className="modal-form-wrap" onClick={(e) => e.stopPropagation()}>
|
||||
<ConferenceFormCard
|
||||
conference={editingConference}
|
||||
onSaved={() => setEditingConference(null)}
|
||||
onDeleted={() => setEditingConference(null)}
|
||||
onCancelEdit={() => setEditingConference(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConferenceHoverCard conferenceId={hoverTarget?.conferenceId ?? null} anchorEl={hoverTarget?.anchorEl ?? null} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
276
frontend/src/pages/ProfilePage.tsx
Normal file
276
frontend/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useRef, useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Trash2, Upload } from 'lucide-react'
|
||||
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { changePassword, deleteMyAvatar, listTeams, updateMe, uploadMyAvatar } from '@/api/users'
|
||||
import { ApiError, errorDetail } from '@/api/client'
|
||||
import '@/styles/lobby.css'
|
||||
import '@/styles/profile.css'
|
||||
|
||||
/** Разрешённые типы файла аватара — те же ограничения, что и на backend. */
|
||||
const ALLOWED_AVATAR_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||
const MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Страница профиля. Своя (текущий пользователь):
|
||||
* ФИО и команда редактируются, email — только чтение, аватар — загрузка с
|
||||
* клиентской предпроверкой типа/размера (дублирует лимиты backend — 413/415
|
||||
* обрабатываются и на случай, если предпроверка почему-то разошлась с
|
||||
* реальным лимитом сервера).
|
||||
*
|
||||
* Справочник команд — аутентифицированный `GET /api/v1/teams` (не зависит
|
||||
* от тумблера «разрешить выбор команды при регистрации», в отличие от
|
||||
* `GET /auth/registration-options`, который для этого экрана не подходит).
|
||||
*/
|
||||
export function ProfilePage() {
|
||||
const { user, refreshUser } = useAuth()
|
||||
const toast = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [name, setName] = useState(user?.name_user ?? '')
|
||||
const [teamId, setTeamId] = useState(user?.team_id ?? '')
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null)
|
||||
|
||||
const { data: teams } = useQuery({ queryKey: ['teams'], queryFn: listTeams })
|
||||
const teamOptions = [{ value: '', label: 'Без команды' }, ...((teams ?? []).map((t) => ({ value: t.id, label: t.name })))]
|
||||
|
||||
async function afterProfileChange() {
|
||||
await refreshUser()
|
||||
queryClient.invalidateQueries({ queryKey: ['conferences'] })
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => updateMe({ name_user: name.trim(), team_id: teamId === '' ? null : teamId }),
|
||||
onSuccess: async () => {
|
||||
await afterProfileChange()
|
||||
toast.show('Профиль сохранён', 'success')
|
||||
},
|
||||
onError: () => toast.show('Не удалось сохранить профиль', 'error'),
|
||||
})
|
||||
|
||||
const avatarMutation = useMutation({
|
||||
mutationFn: (file: File) => uploadMyAvatar(file),
|
||||
onSuccess: async () => {
|
||||
await afterProfileChange()
|
||||
toast.show('Аватар обновлён', 'success')
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 413) {
|
||||
toast.show('Файл слишком большой — максимум 2 МБ', 'error')
|
||||
} else if (err instanceof ApiError && err.status === 415) {
|
||||
toast.show('Недопустимый формат — только JPEG, PNG или WEBP', 'error')
|
||||
} else {
|
||||
toast.show('Не удалось загрузить аватар', 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const deleteAvatarMutation = useMutation({
|
||||
mutationFn: () => deleteMyAvatar(),
|
||||
onSuccess: async () => {
|
||||
await afterProfileChange()
|
||||
toast.show('Аватар удалён', 'success')
|
||||
},
|
||||
onError: () => toast.show('Не удалось удалить аватар', 'error'),
|
||||
})
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null)
|
||||
|
||||
const passwordMutation = useMutation({
|
||||
mutationFn: () => changePassword({ current_password: currentPassword, new_password: newPassword }),
|
||||
onSuccess: () => {
|
||||
toast.show('Пароль изменён', 'success')
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'invalid_current_password') {
|
||||
setPasswordError('Неверный текущий пароль')
|
||||
} else {
|
||||
toast.show('Не удалось сменить пароль', 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function handlePasswordSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setPasswordError(null)
|
||||
if (newPassword.length < 8) {
|
||||
setPasswordError('Новый пароль должен быть не короче 8 символов')
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError('Пароли не совпадают')
|
||||
return
|
||||
}
|
||||
passwordMutation.mutate()
|
||||
}
|
||||
|
||||
function handleFileChange(file: File | null) {
|
||||
setAvatarError(null)
|
||||
if (!file) return
|
||||
if (!ALLOWED_AVATAR_TYPES.includes(file.type)) {
|
||||
setAvatarError('Недопустимый формат — только JPEG, PNG или WEBP')
|
||||
return
|
||||
}
|
||||
if (file.size > MAX_AVATAR_BYTES) {
|
||||
setAvatarError('Файл слишком большой — максимум 2 МБ')
|
||||
return
|
||||
}
|
||||
avatarMutation.mutate(file)
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<div className="page-shell">
|
||||
<ShellTopbar />
|
||||
<main className="page-main">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Профиль</h1>
|
||||
<p>Личные данные, команда и фото — видны другим участникам в составе конференций.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="profile-card">
|
||||
<div className="profile-avatar-block">
|
||||
<Avatar name={user.name_user} avatarUrl={user.avatar_url} size={88} />
|
||||
<div className="profile-avatar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={avatarMutation.isPending}
|
||||
>
|
||||
<Upload style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
{avatarMutation.isPending ? 'Загружаем…' : 'Загрузить фото'}
|
||||
</button>
|
||||
{user.avatar_url && (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
aria-label="Удалить аватар"
|
||||
onClick={() => deleteAvatarMutation.mutate()}
|
||||
disabled={deleteAvatarMutation.isPending}
|
||||
>
|
||||
<Trash2 style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
handleFileChange(e.target.files?.[0] ?? null)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{avatarError && <p className="field-hint" style={{ color: 'var(--color-danger)' }}>{avatarError}</p>}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
saveMutation.mutate()
|
||||
}}
|
||||
>
|
||||
<div className="field">
|
||||
<label htmlFor="profile-name">ФИО</label>
|
||||
<input id="profile-name" type="text" value={name} onChange={(e) => setName(e.target.value)} maxLength={255} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="profile-email">Email</label>
|
||||
<input id="profile-email" type="email" value={user.email} disabled readOnly />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label id="profile-team-label" htmlFor="profile-team">
|
||||
Команда
|
||||
</label>
|
||||
<Select
|
||||
id="profile-team"
|
||||
aria-labelledby="profile-team-label"
|
||||
value={teamId ?? ''}
|
||||
onChange={setTeamId}
|
||||
options={teamOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={saveMutation.isPending || !name.trim()}>
|
||||
{saveMutation.isPending ? 'Сохраняем…' : 'Сохранить изменения'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="profile-card">
|
||||
<h2>Смена пароля</h2>
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<div className="field">
|
||||
<label htmlFor="profile-current-password">Текущий пароль</label>
|
||||
<input
|
||||
id="profile-current-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="profile-new-password">Новый пароль</label>
|
||||
<input
|
||||
id="profile-new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="Минимум 8 символов"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`field${passwordError ? ' has-error' : ''}`}>
|
||||
<label htmlFor="profile-confirm-password">Подтверждение пароля</label>
|
||||
<input
|
||||
id="profile-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
{passwordError && (
|
||||
<p className="field-hint" style={{ color: 'var(--color-danger)' }}>
|
||||
{passwordError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={passwordMutation.isPending || !currentPassword || !newPassword || !confirmPassword}
|
||||
>
|
||||
{passwordMutation.isPending ? 'Сохраняем…' : 'Сменить пароль'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
193
frontend/src/pages/RegisterPage.tsx
Normal file
193
frontend/src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Clock, Mail, Lock, User, Users } from 'lucide-react'
|
||||
import { AuthLayout } from '@/components/auth/AuthLayout'
|
||||
import { getRegistrationOptions, register } from '@/api/auth'
|
||||
import { ApiError, errorDetail } from '@/api/client'
|
||||
|
||||
/** Текст предупреждения о несовпадении домена почты с эталонным. */
|
||||
function domainMismatchMessage(domain: string): string {
|
||||
return `Укажите рабочую почту — регистрация доступна только для домена @${domain}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Экран регистрации (см. design/mockups/auth.html, блок «регистрация»).
|
||||
* Поле «Команда» показывается только если выбор команды включён в
|
||||
* настройках инстанса (`GET /auth/registration-options`, публичный эндпоинт).
|
||||
* Поле «Рабочая почта» дополнительно сверяется с эталонным доменом
|
||||
* (`registration-options.email_domain`), если в админке включена
|
||||
* верификация по домену — проверка идёт по blur и при сабмите, backend
|
||||
* при включённой верификации и чужом домене отвечает 400 `invalid_email_domain`.
|
||||
* После успешной регистрации показывает состояние «подтвердите почту»
|
||||
* (письмо со ссылкой backend в dev-режиме печатает в консоль).
|
||||
*/
|
||||
export function RegisterPage() {
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [teamId, setTeamId] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [emailDomainError, setEmailDomainError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null)
|
||||
|
||||
const { data: registrationOptions } = useQuery({
|
||||
queryKey: ['auth', 'registration-options'],
|
||||
queryFn: getRegistrationOptions,
|
||||
})
|
||||
const teamChoiceEnabled = registrationOptions?.team_choice_enabled ?? false
|
||||
const teams = registrationOptions?.teams ?? []
|
||||
const expectedEmailDomain = registrationOptions?.email_domain ?? null
|
||||
|
||||
/** Домен после «@» не совпадает (без учёта регистра) с эталонным — `null`, если сверять не с чем. */
|
||||
function checkEmailDomain(value: string): string | null {
|
||||
if (!expectedEmailDomain) return null
|
||||
const atIndex = value.lastIndexOf('@')
|
||||
if (atIndex === -1) return null
|
||||
const domain = value.slice(atIndex + 1).trim().toLowerCase()
|
||||
if (domain !== expectedEmailDomain.toLowerCase()) {
|
||||
return domainMismatchMessage(expectedEmailDomain)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
|
||||
const domainError = checkEmailDomain(email)
|
||||
setEmailDomainError(domainError)
|
||||
if (domainError) return
|
||||
|
||||
setPending(true)
|
||||
try {
|
||||
await register({ email, name_user: name, password, team_id: teamChoiceEnabled ? teamId || null : null })
|
||||
setRegisteredEmail(email)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
setError('Пользователь с таким email уже зарегистрирован')
|
||||
} else if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'invalid_email_domain') {
|
||||
setEmailDomainError(expectedEmailDomain ? domainMismatchMessage(expectedEmailDomain) : 'Регистрация с этим доменом почты недоступна')
|
||||
} else {
|
||||
setError('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова')
|
||||
}
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (registeredEmail) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<section className="screen-block">
|
||||
<div className="confirm-card pending">
|
||||
<div className="confirm-icon">
|
||||
<Clock className="lucide" aria-hidden="true" />
|
||||
</div>
|
||||
<h3>Подтвердите почту</h3>
|
||||
<p>
|
||||
Мы отправили письмо со ссылкой на{' '}
|
||||
<span className="mail-chip">
|
||||
<Mail className="lucide" style={{ width: 14, height: 14 }} aria-hidden="true" />
|
||||
{registeredEmail}
|
||||
</span>
|
||||
</p>
|
||||
<Link to="/login" className="btn btn-secondary btn-block btn-lg">
|
||||
Перейти ко входу
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<section className="screen-block">
|
||||
<div className="tabs" role="tablist" aria-label="Способ авторизации">
|
||||
<Link to="/login" className="tab" role="tab" aria-selected="false">
|
||||
Вход
|
||||
</Link>
|
||||
<span className="tab is-active" role="tab" aria-selected="true">
|
||||
Регистрация
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="screen-title">Создать аккаунт</h2>
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<div className="field">
|
||||
<label htmlFor="reg-name">Имя и фамилия</label>
|
||||
<div className="input-wrap">
|
||||
<User className="lucide field-icon" aria-hidden="true" />
|
||||
<input
|
||||
id="reg-name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Мария Кузнецова"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`field${emailDomainError ? ' has-error' : ''}`}>
|
||||
<label htmlFor="reg-email">Рабочая почта</label>
|
||||
<div className="input-wrap">
|
||||
<Mail className="lucide field-icon" aria-hidden="true" />
|
||||
<input
|
||||
id="reg-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="you@company.ru"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onBlur={(e) => setEmailDomainError(checkEmailDomain(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
{emailDomainError && <p className="error-msg">{emailDomainError}</p>}
|
||||
</div>
|
||||
{teamChoiceEnabled && (
|
||||
<div className="field">
|
||||
<label htmlFor="reg-team">Команда</label>
|
||||
<div className="input-wrap">
|
||||
<Users className="lucide field-icon" aria-hidden="true" />
|
||||
<select id="reg-team" value={teamId} onChange={(e) => setTeamId(e.target.value)}>
|
||||
<option value="">Иное</option>
|
||||
{teams.map((team) => (
|
||||
<option key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`field${error ? ' has-error' : ''}`}>
|
||||
<label htmlFor="reg-pass">Пароль</label>
|
||||
<div className="input-wrap">
|
||||
<Lock className="lucide field-icon" aria-hidden="true" />
|
||||
<input
|
||||
id="reg-pass"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="Минимум 8 символов"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="error-msg">{error}</p>
|
||||
) : (
|
||||
<p className="hint-msg">Минимум 8 символов, буквы и цифры</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary btn-block btn-lg" disabled={pending}>
|
||||
{pending ? 'Регистрируем…' : 'Зарегистрироваться'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
250
frontend/src/pages/RoomPage.tsx
Normal file
250
frontend/src/pages/RoomPage.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { PictureInPicture2 } from 'lucide-react'
|
||||
import { LiveKitRoom, usePersistentUserChoices } from '@livekit/components-react'
|
||||
import type { RoomOptions } from 'livekit-client'
|
||||
import '@livekit/components-styles'
|
||||
import '@/styles/room.css'
|
||||
import { joinConference, resolveConference } from '@/api/conferences'
|
||||
import { ApiError, errorDetail } from '@/api/client'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { useChat } from '@/hooks/useChat'
|
||||
import { useFullscreen } from '@/hooks/useFullscreen'
|
||||
import { useRoomPiP } from '@/hooks/useRoomPiP'
|
||||
import { RoomTopbar } from '@/components/room/RoomTopbar'
|
||||
import { RoomStage } from '@/components/room/RoomStage'
|
||||
import { RoomToolbar } from '@/components/room/RoomToolbar'
|
||||
import { ChatPanel } from '@/components/room/ChatPanel'
|
||||
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
||||
|
||||
interface RoomJoinState {
|
||||
livekitUrl: string
|
||||
token: string
|
||||
roomName: string
|
||||
conferenceId?: string
|
||||
/** Номер конференции — для инвайт-чипа в топбаре (см. RoomTopbar). Есть только сразу после создания. */
|
||||
number?: string
|
||||
/** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */
|
||||
chatEnabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Экран комнаты конференции (тёмная тема, `data-theme="room"`).
|
||||
*
|
||||
* Публичный маршрут: гость входит без JWT-аутентификации приложения — его
|
||||
* LiveKit-токен получен через `POST /conferences/{id}/guest-join` (см.
|
||||
* JoinPage) и передан сюда через navigation state, ровно как и токен
|
||||
* авторизованного пользователя. RoomPage сам никогда не проверяет сессию —
|
||||
* подключение к LiveKit целиком определяется переданными `token`/`livekitUrl`.
|
||||
*
|
||||
* `:slug` в адресе — тот же идентификатор (slug или номер), что пользователь
|
||||
* вводил на JoinPage; используется только для повторного резолва при прямой
|
||||
* перезагрузке страницы (join-state из state теряется при F5). Если состояния
|
||||
* нет — не пытаемся угадать креды сами: гостю это в принципе невозможно
|
||||
* (нет сохранённых имени/пароля), поэтому просто уводим на `/join?q=:slug`,
|
||||
* где резолв и вход повторяются штатным флоу.
|
||||
*/
|
||||
export function RoomPage() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { status } = useAuth()
|
||||
|
||||
const initialState = (location.state as RoomJoinState | null) ?? null
|
||||
const [joinState, setJoinState] = useState<RoomJoinState | null>(initialState)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// useCallback: стабильная ссылка на обработчик — иначе на каждом рендере
|
||||
// RoomPage пересобирался бы объект-пропс `onDisconnected`, а с ним и
|
||||
// внутренний useEffect @livekit/components-react (он в списке зависимостей
|
||||
// хука useLiveKitRoom), лишний раз пересоздавая подписки на события Room.
|
||||
const handleDisconnected = useCallback(() => {
|
||||
navigate(status === 'authenticated' ? '/lobby' : '/join')
|
||||
}, [navigate, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (joinState || !slug) return
|
||||
// Сессия ещё восстанавливается (silent-refresh) — подождём, чтобы не
|
||||
// отправить авторизованного пользователя в гостевой флоу по ошибке.
|
||||
if (status === 'loading') return
|
||||
|
||||
if (status !== 'authenticated') {
|
||||
navigate(`/join?q=${encodeURIComponent(slug)}`, { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
// Авторизованный пользователь перезагрузил страницу комнаты — пробуем
|
||||
// молча переподключиться тем же slug/номером, что был в адресе.
|
||||
let cancelled = false
|
||||
|
||||
async function reconnect() {
|
||||
try {
|
||||
const info = await resolveConference(slug!)
|
||||
if (info.requires_password) {
|
||||
if (!cancelled) navigate(`/join?q=${encodeURIComponent(slug!)}`, { replace: true })
|
||||
return
|
||||
}
|
||||
const result = await joinConference(info.id)
|
||||
if (!cancelled) {
|
||||
setJoinState({
|
||||
livekitUrl: result.livekit_url,
|
||||
token: result.token,
|
||||
roomName: result.room_name,
|
||||
conferenceId: result.conference_id,
|
||||
chatEnabled: result.chat_enabled,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
if (err instanceof ApiError && (err.status === 404 || err.status === 410)) {
|
||||
setError('Конференция недоступна или уже завершилась')
|
||||
} else if (err instanceof ApiError && err.status === 403 && errorDetail(err) === 'password_required') {
|
||||
navigate(`/join?q=${encodeURIComponent(slug!)}`, { replace: true })
|
||||
} else {
|
||||
setError('Не удалось подключиться к конференции. Попробуйте войти ещё раз')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void reconnect()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [joinState, slug, status, navigate])
|
||||
|
||||
// WS-соединение чата живёт независимо от того, открыта ли панель, — иначе
|
||||
// при сворачивании панели терялась бы история и обрывался счётчик
|
||||
// непрочитанных. Хук вызывается безусловно (до ранних return ниже) —
|
||||
// conferenceId/token до входа в комнату ещё не известны, enabled это учитывает.
|
||||
const chat = useChat({
|
||||
conferenceId: joinState?.conferenceId,
|
||||
token: joinState?.token,
|
||||
enabled: Boolean(joinState?.chatEnabled),
|
||||
})
|
||||
const [chatOpen, setChatOpen] = useState(false)
|
||||
// Сколько сообщений уже «видели» (на момент последнего открытия панели) —
|
||||
// считается прямо в теле рендера, под охраной сравнения («adjusting state
|
||||
// during rendering», тот же санкционированный паттерн, что и focus-логика
|
||||
// в RoomStage.tsx); useEffect тут не подходит — react-hooks/set-state-in-effect,
|
||||
// а ref нельзя ни читать, ни писать во время рендера — react-hooks/refs.
|
||||
const [chatSeenCount, setChatSeenCount] = useState(0)
|
||||
if (chatOpen && chatSeenCount !== chat.messages.length) {
|
||||
setChatSeenCount(chat.messages.length)
|
||||
}
|
||||
// Счётчик непрочитанных — сообщения, накопленные с момента последнего
|
||||
// открытия панели (включая историю при первом подключении, если панель
|
||||
// открывается впервые уже после её загрузки); при закрытой панели счётчик
|
||||
// сохраняется (см. design/mockups/room.html, .collapsed-demo).
|
||||
const chatUnreadCount = Math.max(0, chat.messages.length - chatSeenCount)
|
||||
|
||||
// Чат выключен на сервере (close-код 4404) — панель нужно скрыть, даже если
|
||||
// изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте).
|
||||
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
|
||||
|
||||
// Корневой контейнер комнаты — цель для fullscreen и источник video-элемента
|
||||
// для video-PiP-фолбэка.
|
||||
const roomRootRef = useRef<HTMLDivElement>(null)
|
||||
const fullscreen = useFullscreen(roomRootRef)
|
||||
const pip = useRoomPiP(roomRootRef)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
// Сохранённый выбор устройств — читаем через собственный вызов
|
||||
// usePersistentUserChoices (независимый от того, что использует
|
||||
// DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО:
|
||||
// ссылка на `roomOptions` должна быть стабильной между рендерами — иначе
|
||||
// LiveKitRoom получает «новый» объект `options` и переподключается
|
||||
// Это безопасно мемоизировать по `userChoices`,
|
||||
// потому что `userChoices` ЭТОГО вызова хука меняется, только если МЫ САМИ
|
||||
// вызовем saveAudioInputDeviceId/saveVideoInputDeviceId НА НЁМ — а мы этого
|
||||
// не делаем (сохранение — только в DeviceSettingsDialog).
|
||||
const { userChoices } = usePersistentUserChoices()
|
||||
const roomOptions = useMemo<RoomOptions>(
|
||||
() => ({
|
||||
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
|
||||
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
|
||||
}),
|
||||
[userChoices],
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div data-theme="room" className="room-error">
|
||||
<p>{error}</p>
|
||||
<button type="button" className="btn btn-primary" onClick={() => navigate('/lobby')}>
|
||||
Вернуться в лобби
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!joinState) {
|
||||
return (
|
||||
<div data-theme="room" className="room-loader" role="status" aria-live="polite">
|
||||
Подключаемся к конференции…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-theme="room" ref={roomRootRef}>
|
||||
<LiveKitRoom
|
||||
serverUrl={joinState.livekitUrl}
|
||||
token={joinState.token}
|
||||
connect
|
||||
audio
|
||||
video
|
||||
options={roomOptions}
|
||||
onDisconnected={handleDisconnected}
|
||||
>
|
||||
<div data-lk-theme="default" className="room-shell">
|
||||
<RoomTopbar roomName={joinState.roomName} slug={slug} number={joinState.number} />
|
||||
<div className="room-main">
|
||||
{pip.mode === 'document' ? (
|
||||
// Сцена сейчас рисуется в PiP-окне (через createPortal ниже) —
|
||||
// основное окно вместо неё показывает заглушку с возвратом.
|
||||
<div className="room-pip-placeholder">
|
||||
<PictureInPicture2 aria-hidden="true" />
|
||||
<p>Конференция открыта в отдельном мини-окне</p>
|
||||
<button type="button" className="btn btn-secondary" onClick={pip.toggle}>
|
||||
Вернуть в это окно
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<RoomStage />
|
||||
)}
|
||||
{chatVisible && chatOpen && (
|
||||
<ChatPanel
|
||||
messages={chat.messages}
|
||||
status={chat.status}
|
||||
statusMessage={chat.statusMessage}
|
||||
onSend={chat.sendMessage}
|
||||
onClose={() => setChatOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<RoomToolbar
|
||||
chatVisible={chatVisible}
|
||||
chatOpen={chatOpen}
|
||||
chatUnreadCount={chatUnreadCount}
|
||||
onToggleChat={() => setChatOpen((open) => !open)}
|
||||
onToggleSettings={() => setSettingsOpen(true)}
|
||||
fullscreenSupported={fullscreen.supported}
|
||||
fullscreenActive={fullscreen.active}
|
||||
onToggleFullscreen={fullscreen.toggle}
|
||||
pipSupported={pip.supported}
|
||||
pipActive={pip.active}
|
||||
onTogglePiP={pip.toggle}
|
||||
/>
|
||||
</div>
|
||||
{settingsOpen && <DeviceSettingsDialog onClose={() => setSettingsOpen(false)} />}
|
||||
{/* Портал внутрь LiveKitRoom (не как сосед снаружи!) — RoomStage,
|
||||
рендерящийся в PiP-окне, продолжает читать RoomContext/треки.
|
||||
`variant="pip"` — мини-плеер
|
||||
показывает только активное окно (одну плитку), без карусели/грида
|
||||
основного окна. */}
|
||||
{pip.pipWindow && createPortal(<RoomStage variant="pip" />, pip.pipWindow.document.body)}
|
||||
</LiveKitRoom>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
93
frontend/src/pages/VerifyEmailPage.tsx
Normal file
93
frontend/src/pages/VerifyEmailPage.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { AlertTriangle, ArrowRight, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
import { AuthLayout } from '@/components/auth/AuthLayout'
|
||||
import { verifyEmail } from '@/api/auth'
|
||||
|
||||
type VerifyState = 'verifying' | 'success' | 'error'
|
||||
|
||||
/**
|
||||
* Кэш промисов подтверждения по токену (модульный, вне компонента) — тот же
|
||||
* приём дедупликации, что и у refreshPromise в api/client.ts. Защищает от
|
||||
* повторного POST /auth/verify-email при двойном монтировании эффекта в
|
||||
* React StrictMode (dev): без кэша второй запрос уходит с уже погашенным
|
||||
* токеном и backend отвечает 400 «токен уже использован», хотя первый запрос
|
||||
* уже успешно подтвердил почту.
|
||||
*/
|
||||
const verifyRequests = new Map<string, Promise<void>>()
|
||||
|
||||
function verifyEmailOnce(token: string): Promise<void> {
|
||||
let request = verifyRequests.get(token)
|
||||
if (!request) {
|
||||
request = verifyEmail(token)
|
||||
verifyRequests.set(token, request)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
/**
|
||||
* Экран подтверждения email по ссылке из письма: /verify-email?token=...
|
||||
* (см. design/mockups/auth.html, блок «подтверждение email»).
|
||||
*/
|
||||
export function VerifyEmailPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
const [state, setState] = useState<VerifyState>(token ? 'verifying' : 'error')
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
let cancelled = false
|
||||
verifyEmailOnce(token)
|
||||
.then(() => {
|
||||
if (!cancelled) setState('success')
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setState('error')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<section className="screen-block">
|
||||
{state === 'verifying' && (
|
||||
<div className="confirm-card pending">
|
||||
<div className="confirm-icon">
|
||||
<Loader2 className="lucide spin" aria-hidden="true" />
|
||||
</div>
|
||||
<h3>Проверяем ссылку…</h3>
|
||||
<p>Подтверждаем вашу почту, это займёт пару секунд.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'success' && (
|
||||
<div className="confirm-card">
|
||||
<div className="confirm-icon">
|
||||
<CheckCircle2 className="lucide" aria-hidden="true" />
|
||||
</div>
|
||||
<h3>Почта подтверждена</h3>
|
||||
<p>Аккаунт активирован. Теперь доступно бронирование переговорных и вход в конференции.</p>
|
||||
<Link to="/login" className="btn btn-primary btn-block btn-lg">
|
||||
Перейти ко входу <ArrowRight className="lucide" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<div className="confirm-card error">
|
||||
<div className="confirm-icon">
|
||||
<AlertTriangle className="lucide" aria-hidden="true" />
|
||||
</div>
|
||||
<h3>Ссылка недействительна</h3>
|
||||
<p>Срок действия ссылки истёк или она уже была использована. Запросите письмо ещё раз со страницы регистрации.</p>
|
||||
<Link to="/register" className="btn btn-secondary btn-block btn-lg">
|
||||
Вернуться к регистрации
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
150
frontend/src/styles/admin.css
Normal file
150
frontend/src/styles/admin.css
Normal 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); }
|
||||
270
frontend/src/styles/auth.css
Normal file
270
frontend/src/styles/auth.css
Normal 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); }
|
||||
}
|
||||
273
frontend/src/styles/calendar.css
Normal file
273
frontend/src/styles/calendar.css
Normal 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); }
|
||||
}
|
||||
80
frontend/src/styles/join.css
Normal file
80
frontend/src/styles/join.css
Normal 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); }
|
||||
210
frontend/src/styles/lobby.css
Normal file
210
frontend/src/styles/lobby.css
Normal 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; }
|
||||
}
|
||||
85
frontend/src/styles/my-conferences.css
Normal file
85
frontend/src/styles/my-conferences.css
Normal 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; }
|
||||
}
|
||||
25
frontend/src/styles/profile.css
Normal file
25
frontend/src/styles/profile.css
Normal 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); }
|
||||
482
frontend/src/styles/room.css
Normal file
482
frontend/src/styles/room.css
Normal 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 при 1–2 участниках, см. выше). `!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;
|
||||
}
|
||||
126
frontend/src/styles/select.css
Normal file
126
frontend/src/styles/select.css
Normal 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);
|
||||
}
|
||||
455
frontend/src/styles/shell.css
Normal file
455
frontend/src/styles/shell.css
Normal 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); }
|
||||
315
frontend/src/styles/tokens.css
Normal file
315
frontend/src/styles/tokens.css
Normal 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;
|
||||
}
|
||||
18
frontend/src/types/document-picture-in-picture.d.ts
vendored
Normal file
18
frontend/src/types/document-picture-in-picture.d.ts
vendored
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user