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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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