Первоначальная версия VidConf
This commit is contained in:
450
frontend/src/components/admin/AdminConferencesTab.tsx
Normal file
450
frontend/src/components/admin/AdminConferencesTab.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Check, Clock, Lock, Mail, Pencil, Play, Repeat, Search, Send, Trash2, X } from 'lucide-react'
|
||||
import {
|
||||
deleteAdminConference,
|
||||
listAdminConferences,
|
||||
sendConferenceInvitations,
|
||||
updateAdminConference,
|
||||
type AdminConferenceOut,
|
||||
type AdminConferenceUpdatePayload,
|
||||
} from '@/api/admin'
|
||||
import type { ConferenceStatus, SummaryRecipientsMode } from '@/api/conferences'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
import { formatLocalDateTime, localDateTimeToIso, toLocalDateTimeParts } from '@/lib/localTime'
|
||||
import { formatRecurrenceSummary } from '@/lib/recurrenceFormat'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
const STATUS_FILTERS: { value: ConferenceStatus | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: 'Все' },
|
||||
{ value: 'scheduled', label: 'Запланированные' },
|
||||
{ value: 'active', label: 'Активные' },
|
||||
{ value: 'ended', label: 'Завершённые' },
|
||||
]
|
||||
|
||||
function StatusBadge({ conference }: { conference: AdminConferenceOut }) {
|
||||
if (conference.status === 'active') {
|
||||
return (
|
||||
<span className="badge badge-free">
|
||||
<Play style={{ width: 12, height: 12 }} aria-hidden="true" />
|
||||
Идёт сейчас
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (conference.status === 'ended') {
|
||||
return (
|
||||
<span className="badge badge-locked">
|
||||
<Check style={{ width: 12, height: 12 }} aria-hidden="true" />
|
||||
Завершена
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="badge badge-booked">
|
||||
<Clock style={{ width: 12, height: 12 }} aria-hidden="true" />
|
||||
Запланирована
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Форма быстрого редактирования конференции из админки (title/время/пароль/рассылка). */
|
||||
function EditConferenceModal({ conference, onClose }: { conference: AdminConferenceOut; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
const initialSlot = conference.scheduled_at ? toLocalDateTimeParts(conference.scheduled_at) : { date: '', time: '' }
|
||||
|
||||
const [title, setTitle] = useState(conference.title ?? '')
|
||||
const [dateStr, setDateStr] = useState(initialSlot.date)
|
||||
const [timeStr, setTimeStr] = useState(initialSlot.time)
|
||||
const [durationMinutes, setDurationMinutes] = useState(conference.duration_minutes ?? 30)
|
||||
const [isClosed, setIsClosed] = useState(conference.is_closed)
|
||||
const [password, setPassword] = useState('')
|
||||
const [summaryRecipients, setSummaryRecipients] = useState<'' | SummaryRecipientsMode>(
|
||||
conference.summary_recipients ?? '',
|
||||
)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: AdminConferenceUpdatePayload) => updateAdminConference(conference.id, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'conferences'] })
|
||||
toast.show('Изменения сохранены', 'success')
|
||||
onClose()
|
||||
},
|
||||
onError: () => toast.show('Не удалось сохранить изменения', 'error'),
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
const payload: AdminConferenceUpdatePayload = {
|
||||
title: title.trim(),
|
||||
duration_minutes: durationMinutes,
|
||||
is_closed: isClosed,
|
||||
summary_recipients: summaryRecipients === '' ? null : summaryRecipients,
|
||||
}
|
||||
if (dateStr && timeStr) payload.scheduled_at = localDateTimeToIso(dateStr, timeStr)
|
||||
if (isClosed && password) payload.password = password
|
||||
mutation.mutate(payload)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={onClose}>
|
||||
<div className="modal-panel is-wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<h2>Редактирование конференции</h2>
|
||||
<button type="button" className="modal-close" aria-label="Закрыть" onClick={onClose}>
|
||||
<X style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{conference.is_pinned && conference.recurrence && (
|
||||
<p className="field-hint" style={{ marginBottom: 'var(--space-4)' }}>
|
||||
<Repeat style={{ width: 14, height: 14, verticalAlign: '-2px' }} aria-hidden="true" /> Закреплена:{' '}
|
||||
{formatRecurrenceSummary(conference.recurrence)} — повторение редактируется владельцем в календаре.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}}
|
||||
>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-conf-title">Название</label>
|
||||
<input id="admin-conf-title" type="text" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={255} />
|
||||
</div>
|
||||
|
||||
{!conference.is_pinned && (
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label htmlFor="admin-conf-date">Дата</label>
|
||||
<input id="admin-conf-date" type="date" value={dateStr} onChange={(e) => setDateStr(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-conf-time">Время</label>
|
||||
<input id="admin-conf-time" type="time" value={timeStr} onChange={(e) => setTimeStr(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="admin-conf-duration">Длительность, мин</label>
|
||||
<input
|
||||
id="admin-conf-duration"
|
||||
type="number"
|
||||
min={5}
|
||||
step={5}
|
||||
value={durationMinutes}
|
||||
onChange={(e) => setDurationMinutes(Number(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="toggle-row">
|
||||
<div className="toggle-copy">
|
||||
<strong>Закрытая по паролю</strong>
|
||||
<span>Вход только по паролю, включая гостей</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={isClosed} onChange={(e) => setIsClosed(e.target.checked)} />
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
{isClosed && (
|
||||
<div className="field">
|
||||
<label htmlFor="admin-conf-password">
|
||||
<Lock style={{ width: 14, height: 14 }} aria-hidden="true" />
|
||||
Новый пароль (оставьте пустым, чтобы не менять)
|
||||
</label>
|
||||
<input
|
||||
id="admin-conf-password"
|
||||
type="password"
|
||||
placeholder="Минимум 4 символа"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="admin-conf-recipients">Рассылка саммари</label>
|
||||
<select
|
||||
id="admin-conf-recipients"
|
||||
value={summaryRecipients}
|
||||
onChange={(e) => setSummaryRecipients(e.target.value as '' | SummaryRecipientsMode)}
|
||||
>
|
||||
<option value="">По умолчанию (настройки инстанса)</option>
|
||||
<option value="all">Всем участникам</option>
|
||||
<option value="owner">Только организатору</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Сохраняем…' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Модалка ручной рассылки .ics-приглашений по конкретной конференции. */
|
||||
function InviteModal({ conference, onClose }: { conference: AdminConferenceOut; onClose: () => void }) {
|
||||
const [emailsText, setEmailsText] = useState('')
|
||||
const toast = useToast()
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const emails = emailsText
|
||||
.split(/[\n,;]+/)
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean)
|
||||
return sendConferenceInvitations(conference.id, emails.length > 0 ? emails : undefined)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.show('Рассылка приглашений поставлена в очередь', 'success')
|
||||
onClose()
|
||||
},
|
||||
onError: () => toast.show('Не удалось поставить рассылку', 'error'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={onClose}>
|
||||
<div className="modal-panel is-wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<h2>Рассылка приглашений</h2>
|
||||
<button type="button" className="modal-close" aria-label="Закрыть" onClick={onClose}>
|
||||
<X style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="field-hint" style={{ marginBottom: 'var(--space-4)' }}>
|
||||
«{conference.title ?? conference.number}» — оставьте поле пустым, чтобы разослать по умолчанию (владельцу и
|
||||
участникам прошлых сеансов).
|
||||
</p>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-invite-emails">Адреса (через запятую или с новой строки)</label>
|
||||
<textarea
|
||||
id="admin-invite-emails"
|
||||
rows={5}
|
||||
placeholder="i.sokolov@company.ru o.panina@company.ru"
|
||||
value={emailsText}
|
||||
onChange={(e) => setEmailsText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Отмена
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={() => mutation.mutate()}>
|
||||
<Send style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
{mutation.isPending ? 'Отправляем…' : 'Отправить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Вкладка «Конференции» админки — список запланированных/закреплённых
|
||||
* конференций с поиском, фильтром по статусу, пагинацией, редактированием,
|
||||
* удалением и ручной рассылкой приглашений (design/mockups/admin.html,
|
||||
* блок «table-card», адаптированный под сущность «конференция»).
|
||||
*/
|
||||
export function AdminConferencesTab() {
|
||||
const [statusFilter, setStatusFilter] = useState<ConferenceStatus | 'all'>('all')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [editingConference, setEditingConference] = useState<AdminConferenceOut | null>(null)
|
||||
const [invitingConference, setInvitingConference] = useState<AdminConferenceOut | null>(null)
|
||||
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
|
||||
// Дебаунс поискового ввода — не дёргаем API на каждое нажатие клавиши.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setSearch(searchInput.trim())
|
||||
setOffset(0)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchInput])
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'conferences', statusFilter, search, offset],
|
||||
queryFn: () =>
|
||||
listAdminConferences({
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
q: search || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
}),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteAdminConference(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'conferences'] })
|
||||
toast.show('Конференция удалена', 'success')
|
||||
setConfirmingDeleteId(null)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
toast.show('Нельзя удалить — конференция сейчас идёт', 'error')
|
||||
} else {
|
||||
toast.show('Не удалось удалить конференцию', 'error')
|
||||
}
|
||||
setConfirmingDeleteId(null)
|
||||
},
|
||||
})
|
||||
|
||||
const items = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-tabs-inline">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
type="button"
|
||||
className={`chip${statusFilter === f.value ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setStatusFilter(f.value)
|
||||
setOffset(0)
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="toolbar-row">
|
||||
<div className="search-wrap">
|
||||
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по названию или номеру…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="toolbar-count">
|
||||
{isLoading ? 'Загрузка…' : `Показано ${items.length ? offset + 1 : 0}–${offset + items.length} из ${total}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Конференция</th>
|
||||
<th>Владелец</th>
|
||||
<th>Статус</th>
|
||||
<th>Тип</th>
|
||||
<th>Начало</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 && !isLoading && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-ink-500)' }}>
|
||||
Конференции не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.map((conf) => (
|
||||
<tr key={conf.id}>
|
||||
<td>
|
||||
{conf.is_closed && <Lock style={{ width: 14, height: 14, marginRight: 6, verticalAlign: '-2px' }} aria-hidden="true" />}
|
||||
{conf.title ?? 'Без названия'}
|
||||
<span style={{ display: 'block', font: 'var(--text-caption)', color: 'var(--color-ink-500)' }}>
|
||||
№ {conf.number}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{conf.owner_name ?? '—'}
|
||||
{conf.owner_email && (
|
||||
<span style={{ display: 'block', font: 'var(--text-caption)', color: 'var(--color-ink-500)' }}>
|
||||
{conf.owner_email}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge conference={conf} />
|
||||
</td>
|
||||
<td>
|
||||
{conf.is_pinned ? (
|
||||
<span className="badge badge-locked">
|
||||
<Repeat style={{ width: 12, height: 12 }} aria-hidden="true" />
|
||||
Закреплена
|
||||
</span>
|
||||
) : (
|
||||
'Разовая'
|
||||
)}
|
||||
</td>
|
||||
<td>{conf.next_occurrence ? formatLocalDateTime(conf.next_occurrence) : conf.scheduled_at ? formatLocalDateTime(conf.scheduled_at) : '—'}</td>
|
||||
<td>
|
||||
{confirmingDeleteId === conf.id ? (
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn danger"
|
||||
aria-label="Подтвердить удаление"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate(conf.id)}
|
||||
>
|
||||
<Check style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="icon-btn" aria-label="Отмена" onClick={() => setConfirmingDeleteId(null)}>
|
||||
<X style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row-actions">
|
||||
<button type="button" className="icon-btn" aria-label="Рассылка приглашений" onClick={() => setInvitingConference(conf)}>
|
||||
<Mail style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="icon-btn" aria-label="Редактировать" onClick={() => setEditingConference(conf)}>
|
||||
<Pencil style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="icon-btn danger" aria-label="Удалить" onClick={() => setConfirmingDeleteId(conf.id)}>
|
||||
<Trash2 style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="pagination-row">
|
||||
<button type="button" className="btn btn-secondary" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={offset + PAGE_SIZE >= total}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
>
|
||||
Далее
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingConference && <EditConferenceModal conference={editingConference} onClose={() => setEditingConference(null)} />}
|
||||
{invitingConference && <InviteModal conference={invitingConference} onClose={() => setInvitingConference(null)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
278
frontend/src/components/admin/AdminSettingsTab.tsx
Normal file
278
frontend/src/components/admin/AdminSettingsTab.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { getAdminSettings, updateAdminSettings, type AiLevel, type SettingsOut, type SettingsUpdateIn } from '@/api/admin'
|
||||
import type { SummaryRecipientsMode } from '@/api/conferences'
|
||||
import { ApiError, errorDetail } from '@/api/client'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
|
||||
const SUMMARY_RECIPIENTS_OPTIONS = [
|
||||
{ value: 'all', label: 'Всем участникам' },
|
||||
{ value: 'owner', label: 'Только организатору' },
|
||||
]
|
||||
|
||||
const AI_LEVEL_LABEL: Record<AiLevel, string> = {
|
||||
min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)',
|
||||
medium: 'Средний',
|
||||
max: 'Максимальный',
|
||||
}
|
||||
|
||||
/**
|
||||
* Вкладка «Настройки» админки — раздела нет в макете `admin.html`, построена
|
||||
* по паттерну того же макета (`section-tab`/`table-card` → карточки-секции
|
||||
* с `toggle-row`/radio).
|
||||
* Тумблер «Транскрибация и суммаризация» — единый переключатель AI-модуля
|
||||
* (см. `SettingsOut.transcription_enabled`).
|
||||
*
|
||||
* Форма (`AdminSettingsForm`) вынесена отдельно и монтируется только после
|
||||
* загрузки `data` — локальное состояние инициализируется прямо из пропсов
|
||||
* при монтировании, без `useEffect`-синхронизации (react-hooks/set-state-in-effect).
|
||||
*/
|
||||
export function AdminSettingsTab() {
|
||||
const { data, isLoading } = useQuery({ queryKey: ['admin', 'settings'], queryFn: getAdminSettings })
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <p className="field-hint">Загрузка настроек…</p>
|
||||
}
|
||||
|
||||
return <AdminSettingsForm data={data} />
|
||||
}
|
||||
|
||||
function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
|
||||
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
|
||||
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
|
||||
const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level)
|
||||
const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients)
|
||||
const [timezone, setTimezone] = useState(data.display_timezone)
|
||||
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
|
||||
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
|
||||
const [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '')
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload),
|
||||
onSuccess: (result) => {
|
||||
queryClient.setQueryData(['admin', 'settings'], result)
|
||||
toast.show('Настройки сохранены', 'success')
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 400) {
|
||||
toast.show(errorDetail(err) ?? 'Недоступное значение — проверьте уровень AI, таймзону и домен почты', 'error')
|
||||
} else {
|
||||
toast.show('Не удалось сохранить настройки', 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function handleSave() {
|
||||
mutation.mutate({
|
||||
chat_enabled: chatEnabled,
|
||||
transcription_enabled: aiEnabled,
|
||||
ai_level: aiLevel,
|
||||
summary_recipients: recipients,
|
||||
display_timezone: timezone.trim(),
|
||||
registration_team_choice: teamChoiceEnabled,
|
||||
registration_email_domain_enabled: domainVerificationEnabled,
|
||||
registration_email_domain: emailDomain.trim() || null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
<section className="settings-card">
|
||||
<h2>Модули</h2>
|
||||
<p className="desc">Включение и отключение возможностей инстанса — переключатели плагинов из поставки.</p>
|
||||
|
||||
<div className="settings-card-body settings-card-body--spread">
|
||||
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
|
||||
<div className="toggle-copy">
|
||||
<strong>Чат конференции</strong>
|
||||
<span>Текстовый чат внутри комнаты для участников</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={chatEnabled} onChange={(e) => setChatEnabled(e.target.checked)} />
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="toggle-row">
|
||||
<div className="toggle-copy">
|
||||
<strong>Транскрибация и суммаризация (AI)</strong>
|
||||
<span>Батч-обработка записи после конференции: расшифровка речи и саммари</span>
|
||||
{aiEnabled && !data.transcription_queue_served && (
|
||||
<span className="ai-level-reason">
|
||||
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" />
|
||||
AI включён, но обработка очереди транскрибации недоступна — сеансы не будут обработаны
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={aiEnabled} onChange={(e) => setAiEnabled(e.target.checked)} />
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="toggle-row">
|
||||
<div className="toggle-copy">
|
||||
<strong>Разрешить выбор команды при регистрации</strong>
|
||||
<span>На экране регистрации появится поле «Команда» со списком команд инстанса</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={teamChoiceEnabled}
|
||||
onChange={(e) => setTeamChoiceEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="toggle-row">
|
||||
<div className="toggle-copy">
|
||||
<strong>Верификация по домену почты</strong>
|
||||
<span>Регистрация только с email эталонного домена (см. плитку «Эталон mail-домена»)</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={domainVerificationEnabled}
|
||||
onChange={(e) => setDomainVerificationEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Анонс записи конференций. Чекбокс не имеет состояния и не
|
||||
участвует в сохранении формы — функция появится в одной из
|
||||
следующих версий, здесь только заглушка-анонс в общем стиле
|
||||
«Модулей».
|
||||
*/}
|
||||
<div className="toggle-row">
|
||||
<div className="toggle-copy">
|
||||
<strong>Возможность записи конференций</strong>
|
||||
<span>Функция появится в ближайших версиях</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={false} disabled readOnly />
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="settings-stack">
|
||||
<section className="settings-card">
|
||||
<h2>Уровень AI</h2>
|
||||
<p className="desc">Качество распознавания и суммаризации — определяет размер моделей и требования к железу.</p>
|
||||
|
||||
<div className="settings-card-body">
|
||||
<div className="ai-level-list">
|
||||
{data.ai_levels.map((levelStatus) => (
|
||||
<label
|
||||
key={levelStatus.level}
|
||||
className={`ai-level-option${!levelStatus.available ? ' is-disabled' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="ai-level"
|
||||
checked={aiLevel === levelStatus.level}
|
||||
disabled={!levelStatus.available || !aiEnabled}
|
||||
onChange={() => setAiLevel(levelStatus.level)}
|
||||
/>
|
||||
<div>
|
||||
<strong>{AI_LEVEL_LABEL[levelStatus.level]}</strong>
|
||||
{!levelStatus.available && levelStatus.reason && (
|
||||
<span className="ai-level-reason">
|
||||
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" />
|
||||
{levelStatus.reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Эталон mail-домена</h2>
|
||||
<p className="desc">Домен, с которым сверяется email при регистрации, если включена верификация в «Модулях».</p>
|
||||
|
||||
<div className="settings-card-body settings-card-body--center">
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label htmlFor="settings-email-domain">Домен корпоративной почты</label>
|
||||
<input
|
||||
id="settings-email-domain"
|
||||
type="text"
|
||||
placeholder="company.ru"
|
||||
value={emailDomain}
|
||||
disabled={!domainVerificationEnabled}
|
||||
onChange={(e) => setEmailDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Рассылка саммари</h2>
|
||||
<p className="desc">Кому по умолчанию отправляется письмо с саммари после конференции (переопределяется в карточке конференции).</p>
|
||||
|
||||
<div className="settings-card-body settings-card-body--center">
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label id="settings-recipients-label" htmlFor="settings-recipients">
|
||||
Получатели по умолчанию
|
||||
</label>
|
||||
<Select
|
||||
id="settings-recipients"
|
||||
aria-labelledby="settings-recipients-label"
|
||||
value={recipients}
|
||||
onChange={(v) => setRecipients(v as SummaryRecipientsMode)}
|
||||
options={SUMMARY_RECIPIENTS_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Часовой пояс отображения</h2>
|
||||
<p className="desc">IANA-таймзона для времени в письмах и .ics-приглашениях (в БД время всегда хранится в UTC).</p>
|
||||
|
||||
<div className="settings-card-body settings-card-body--center">
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label htmlFor="settings-timezone">Таймзона</label>
|
||||
<input
|
||||
id="settings-timezone"
|
||||
type="text"
|
||||
list="timezone-options"
|
||||
placeholder="Europe/Moscow"
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
/>
|
||||
<datalist id="timezone-options">
|
||||
<option value="Europe/Moscow" />
|
||||
<option value="Europe/Kaliningrad" />
|
||||
<option value="Europe/Samara" />
|
||||
<option value="Asia/Yekaterinburg" />
|
||||
<option value="Asia/Novosibirsk" />
|
||||
<option value="Asia/Krasnoyarsk" />
|
||||
<option value="Asia/Irkutsk" />
|
||||
<option value="Asia/Vladivostok" />
|
||||
<option value="UTC" />
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="settings-actions">
|
||||
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
|
||||
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
241
frontend/src/components/admin/AdminTeamsTab.tsx
Normal file
241
frontend/src/components/admin/AdminTeamsTab.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Check, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||
import { createAdminTeam, deleteAdminTeam, listAdminTeams, renameAdminTeam, type TeamOut } from '@/api/admin'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
/**
|
||||
* Вкладка «Команды» админки — список команд с датой создания, добавление,
|
||||
* инлайн-переименование и удаление с подтверждением. В макете
|
||||
* `design/mockups/admin.html` отдельного экрана для команд нет — вкладка
|
||||
* построена по паттерну `table-card`/`toolbar-row`/`row-actions` того же
|
||||
* макета и `admin.css` (тот же приём, что и вкладка «Настройки»,
|
||||
* см. комментарий в `AdminPage.tsx`).
|
||||
*/
|
||||
export function AdminTeamsTab() {
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editingName, setEditingName] = useState('')
|
||||
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'teams', offset],
|
||||
queryFn: () => listAdminTeams({ limit: PAGE_SIZE, offset }),
|
||||
})
|
||||
|
||||
function invalidate() {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'teams'] })
|
||||
}
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => createAdminTeam(name),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
toast.show('Команда добавлена', 'success')
|
||||
setNewName('')
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
toast.show('Команда с таким названием уже есть', 'error')
|
||||
} else {
|
||||
toast.show('Не удалось добавить команду', 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => renameAdminTeam(id, name),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
toast.show('Команда переименована', 'success')
|
||||
setEditingId(null)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
toast.show('Команда с таким названием уже есть', 'error')
|
||||
} else {
|
||||
toast.show('Не удалось переименовать команду', 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteAdminTeam(id),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
toast.show('Команда удалена', 'success')
|
||||
setConfirmingDeleteId(null)
|
||||
},
|
||||
onError: () => {
|
||||
toast.show('Не удалось удалить команду', 'error')
|
||||
setConfirmingDeleteId(null)
|
||||
},
|
||||
})
|
||||
|
||||
function handleCreate() {
|
||||
const name = newName.trim()
|
||||
if (!name) return
|
||||
createMutation.mutate(name)
|
||||
}
|
||||
|
||||
function startEditing(team: TeamOut) {
|
||||
setEditingId(team.id)
|
||||
setEditingName(team.name)
|
||||
}
|
||||
|
||||
function handleRenameSubmit(id: string) {
|
||||
const name = editingName.trim()
|
||||
if (!name) return
|
||||
renameMutation.mutate({ id, name })
|
||||
}
|
||||
|
||||
const items = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar-row">
|
||||
<form
|
||||
className="team-add-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleCreate()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название новой команды…"
|
||||
value={newName}
|
||||
maxLength={255}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
aria-label="Название новой команды"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary" disabled={createMutation.isPending || !newName.trim()}>
|
||||
<Plus style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
Добавить команду
|
||||
</button>
|
||||
</form>
|
||||
<span className="toolbar-count">
|
||||
{isLoading ? 'Загрузка…' : `Показано ${items.length ? offset + 1 : 0}–${offset + items.length} из ${total}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Создана</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 && !isLoading && (
|
||||
<tr>
|
||||
<td colSpan={3} style={{ textAlign: 'center', color: 'var(--color-ink-500)' }}>
|
||||
Команды не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.map((team) => (
|
||||
<tr key={team.id}>
|
||||
<td>
|
||||
{editingId === team.id ? (
|
||||
<form
|
||||
className="team-rename-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleRenameSubmit(team.id)
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
maxLength={255}
|
||||
autoFocus
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
aria-label="Новое название команды"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
team.name
|
||||
)}
|
||||
</td>
|
||||
<td>{new Date(team.created_at).toLocaleDateString('ru-RU')}</td>
|
||||
<td>
|
||||
{editingId === team.id ? (
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
aria-label="Сохранить название"
|
||||
disabled={renameMutation.isPending}
|
||||
onClick={() => handleRenameSubmit(team.id)}
|
||||
>
|
||||
<Check style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="icon-btn" aria-label="Отмена" onClick={() => setEditingId(null)}>
|
||||
<X style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : confirmingDeleteId === team.id ? (
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn danger"
|
||||
aria-label="Подтвердить удаление"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate(team.id)}
|
||||
>
|
||||
<Check style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="icon-btn" aria-label="Отмена" onClick={() => setConfirmingDeleteId(null)}>
|
||||
<X style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row-actions">
|
||||
<button type="button" className="icon-btn" aria-label="Переименовать" onClick={() => startEditing(team)}>
|
||||
<Pencil style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn danger"
|
||||
aria-label="Удалить"
|
||||
onClick={() => setConfirmingDeleteId(team.id)}
|
||||
>
|
||||
<Trash2 style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="pagination-row">
|
||||
<button type="button" className="btn btn-secondary" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={offset + PAGE_SIZE >= total}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
>
|
||||
Далее
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
148
frontend/src/components/admin/AdminUserCreateDialog.tsx
Normal file
148
frontend/src/components/admin/AdminUserCreateDialog.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { X } from 'lucide-react'
|
||||
import { createAdminUser, listAdminTeams } from '@/api/admin'
|
||||
import { ApiError, errorDetail } from '@/api/client'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
|
||||
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте (как в AdminUsersTab). */
|
||||
const TEAMS_LIMIT = 200
|
||||
|
||||
interface AdminUserCreateDialogProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Диалог «Добавить пользователя» в админке (вкладка «Пользователи») —
|
||||
* по образцу `AdminUserProfileDialog`. ФИО, email,
|
||||
* команда (справочник команд) и пароль; создание сразу с подтверждённой
|
||||
* почтой (`email_verified = true` выставляет backend). Роль остаётся
|
||||
* дефолтной («Пользователь») — сменить её можно потом из таблицы.
|
||||
*/
|
||||
export function AdminUserCreateDialog({ onClose }: AdminUserCreateDialogProps) {
|
||||
const toast = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [teamId, setTeamId] = useState('')
|
||||
const [emailError, setEmailError] = useState<string | null>(null)
|
||||
|
||||
const { data: teamsData } = useQuery({
|
||||
queryKey: ['admin', 'teams', 'all'],
|
||||
queryFn: () => listAdminTeams({ limit: TEAMS_LIMIT, offset: 0 }),
|
||||
})
|
||||
const teams = teamsData?.items ?? []
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createAdminUser({ name_user: name.trim(), email: email.trim(), password, team_id: teamId === '' ? null : teamId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
toast.show('Пользователь создан', 'success')
|
||||
onClose()
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
setEmailError('Email уже зарегистрирован')
|
||||
} else if (err instanceof ApiError && err.status === 404 && errorDetail(err) === 'team_not_found') {
|
||||
toast.show('Выбранная команда не найдена — обновите список команд', 'error')
|
||||
} else {
|
||||
toast.show('Не удалось создать пользователя', 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setEmailError(null)
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
const canSubmit = name.trim().length > 0 && email.trim().length > 0 && password.length >= 8
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="admin-user-create-title" onClick={onClose}>
|
||||
<div className="modal-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<h2 id="admin-user-create-title">Добавить пользователя</h2>
|
||||
<button type="button" className="modal-close" aria-label="Закрыть" onClick={onClose}>
|
||||
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<label htmlFor="admin-create-name">ФИО</label>
|
||||
<input
|
||||
id="admin-create-name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`field${emailError ? ' has-error' : ''}`}>
|
||||
<label htmlFor="admin-create-email">Email</label>
|
||||
<input
|
||||
id="admin-create-email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
setEmailError(null)
|
||||
}}
|
||||
/>
|
||||
{emailError && (
|
||||
<p className="field-hint" style={{ color: 'var(--color-danger)' }}>
|
||||
{emailError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label id="admin-create-team-label" htmlFor="admin-create-team">
|
||||
Команда
|
||||
</label>
|
||||
<Select
|
||||
id="admin-create-team"
|
||||
aria-labelledby="admin-create-team-label"
|
||||
value={teamId}
|
||||
onChange={setTeamId}
|
||||
options={[{ value: '', label: 'Без команды' }, ...teams.map((t) => ({ value: t.id, label: t.name }))]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="admin-create-password">Пароль</label>
|
||||
<input
|
||||
id="admin-create-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="Минимум 8 символов"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="field-hint">Пользователь сможет сменить пароль после входа, в своём профиле</p>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={!canSubmit || mutation.isPending}>
|
||||
{mutation.isPending ? 'Создаём…' : 'Создать'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
219
frontend/src/components/admin/AdminUserProfileDialog.tsx
Normal file
219
frontend/src/components/admin/AdminUserProfileDialog.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Ban, CheckCircle2, Upload, X } from 'lucide-react'
|
||||
import {
|
||||
getAdminUser,
|
||||
listAdminTeams,
|
||||
updateAdminUser,
|
||||
uploadAdminUserAvatar,
|
||||
type AdminUserOut,
|
||||
} from '@/api/admin'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
|
||||
/** Разрешённые типы/лимит размера файла аватара — те же ограничения, что и на ProfilePage/backend. */
|
||||
const ALLOWED_AVATAR_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||
const MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
||||
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте (как в AdminUsersTab). */
|
||||
const TEAMS_LIMIT = 200
|
||||
|
||||
interface AdminUserProfileDialogProps {
|
||||
userId: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Карточка профиля пользователя в админке — открывается
|
||||
* кликом по имени в таблице «Пользователи» (`AdminUsersTab.tsx`). Те же поля,
|
||||
* что и в собственном профиле (`ProfilePage.tsx`) плюс уже существующие
|
||||
* админ-действия «роль»/«блокировка» — та же логика прав (собственная учётная
|
||||
* запись администратора защищена от самоизменения роли/блокировки).
|
||||
*/
|
||||
export function AdminUserProfileDialog({ userId, onClose }: AdminUserProfileDialogProps) {
|
||||
const { user: currentUser } = useAuth()
|
||||
const toast = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null)
|
||||
const [name, setName] = useState<string | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery({ queryKey: ['admin', 'users', userId], queryFn: () => getAdminUser(userId) })
|
||||
const { data: teamsData } = useQuery({
|
||||
queryKey: ['admin', 'teams', 'all'],
|
||||
queryFn: () => listAdminTeams({ limit: TEAMS_LIMIT, offset: 0 }),
|
||||
})
|
||||
const teams = teamsData?.items ?? []
|
||||
const isSelf = userId === currentUser?.id
|
||||
|
||||
function invalidate() {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: Parameters<typeof updateAdminUser>[1]) => updateAdminUser(userId, payload),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users', userId] })
|
||||
toast.show('Изменения сохранены', 'success')
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
toast.show('Нельзя изменить собственную учётную запись', 'error')
|
||||
} else {
|
||||
toast.show('Не удалось сохранить изменения', 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const avatarMutation = useMutation({
|
||||
mutationFn: (file: File) => uploadAdminUserAvatar(userId, file),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users', userId] })
|
||||
toast.show('Аватар обновлён', 'success')
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 413) toast.show('Файл слишком большой — максимум 2 МБ', 'error')
|
||||
else if (err instanceof ApiError && err.status === 415) toast.show('Недопустимый формат — только JPEG, PNG или WEBP', 'error')
|
||||
else toast.show('Не удалось загрузить аватар', 'error')
|
||||
},
|
||||
})
|
||||
|
||||
function handleFileChange(file: File | null) {
|
||||
setAvatarError(null)
|
||||
if (!file) return
|
||||
if (!ALLOWED_AVATAR_TYPES.includes(file.type)) {
|
||||
setAvatarError('Недопустимый формат — только JPEG, PNG или WEBP')
|
||||
return
|
||||
}
|
||||
if (file.size > MAX_AVATAR_BYTES) {
|
||||
setAvatarError('Файл слишком большой — максимум 2 МБ')
|
||||
return
|
||||
}
|
||||
avatarMutation.mutate(file)
|
||||
}
|
||||
|
||||
function handleRoleChange(target: AdminUserOut, role: 'admin' | 'user') {
|
||||
if (role === target.role) return
|
||||
mutation.mutate({ role })
|
||||
}
|
||||
|
||||
function handleToggleBlock(target: AdminUserOut) {
|
||||
mutation.mutate({ is_blocked: !target.is_blocked })
|
||||
}
|
||||
|
||||
function handleTeamChange(teamId: string) {
|
||||
mutation.mutate({ team_id: teamId === '' ? null : teamId })
|
||||
}
|
||||
|
||||
function handleSaveName() {
|
||||
if (name === null) return
|
||||
mutation.mutate({ name_user: name.trim() })
|
||||
}
|
||||
|
||||
const displayName = name ?? data?.name_user ?? ''
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="admin-user-dialog-title" onClick={onClose}>
|
||||
<div className="modal-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<h2 id="admin-user-dialog-title">Профиль пользователя</h2>
|
||||
<button type="button" className="modal-close" aria-label="Закрыть" onClick={onClose}>
|
||||
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading || !data ? (
|
||||
<p className="field-hint">Загрузка…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="profile-avatar-block">
|
||||
<Avatar name={data.name_user} avatarUrl={data.avatar_url} size={72} />
|
||||
<div className="profile-avatar-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => fileInputRef.current?.click()} disabled={avatarMutation.isPending}>
|
||||
<Upload style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
{avatarMutation.isPending ? 'Загружаем…' : 'Загрузить фото'}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
handleFileChange(e.target.files?.[0] ?? null)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{avatarError && <p className="field-hint" style={{ color: 'var(--color-danger)' }}>{avatarError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="admin-user-name">ФИО</label>
|
||||
<input id="admin-user-name" type="text" value={displayName} onChange={(e) => setName(e.target.value)} maxLength={255} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="admin-user-email">Email</label>
|
||||
<input id="admin-user-email" type="email" value={data.email} disabled readOnly />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label id="admin-user-team-label" htmlFor="admin-user-team">
|
||||
Команда
|
||||
</label>
|
||||
<Select
|
||||
id="admin-user-team"
|
||||
aria-labelledby="admin-user-team-label"
|
||||
value={data.team_id ?? ''}
|
||||
onChange={handleTeamChange}
|
||||
disabled={mutation.isPending}
|
||||
options={[{ value: '', label: 'Без команды' }, ...teams.map((t) => ({ value: t.id, label: t.name }))]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label id="admin-user-role-label" htmlFor="admin-user-role">
|
||||
Роль
|
||||
</label>
|
||||
<Select
|
||||
id="admin-user-role"
|
||||
aria-labelledby="admin-user-role-label"
|
||||
value={data.role}
|
||||
onChange={(v) => handleRoleChange(data, v as 'admin' | 'user')}
|
||||
disabled={isSelf || mutation.isPending}
|
||||
options={[
|
||||
{ value: 'user', label: 'Пользователь' },
|
||||
{ value: 'admin', label: 'Администратор' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={handleSaveName} disabled={mutation.isPending || !displayName.trim()}>
|
||||
Сохранить ФИО
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={isSelf || mutation.isPending}
|
||||
title={isSelf ? 'Нельзя изменить собственную учётную запись' : undefined}
|
||||
onClick={() => handleToggleBlock(data)}
|
||||
>
|
||||
{data.is_blocked ? (
|
||||
<CheckCircle2 style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
) : (
|
||||
<Ban style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
)}
|
||||
{data.is_blocked ? 'Разблокировать' : 'Заблокировать'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
233
frontend/src/components/admin/AdminUsersTab.tsx
Normal file
233
frontend/src/components/admin/AdminUsersTab.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Ban, CheckCircle2, Search, UserPlus } from 'lucide-react'
|
||||
import { listAdminTeams, listAdminUsers, updateAdminUser, type AdminUserOut } from '@/api/admin'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { AdminUserCreateDialog } from '@/components/admin/AdminUserCreateDialog'
|
||||
import { AdminUserProfileDialog } from '@/components/admin/AdminUserProfileDialog'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте. */
|
||||
const TEAMS_LIMIT = 200
|
||||
|
||||
/**
|
||||
* Вкладка «Пользователи» админки — список с поиском, пагинацией, сменой
|
||||
* роли, блокировкой и командой (design/mockups/admin.html,
|
||||
* «table-card»/«user-cell»). Собственная учётная запись администратора
|
||||
* защищена от самоизменения на уровне UI (disabled) и backend (409 — на
|
||||
* случай гонки в двух вкладках) — но это касается только роли и блокировки:
|
||||
* смену собственной команды запрет не затрагивает, селект «Команда» для
|
||||
* своей строки не дизейблится.
|
||||
*
|
||||
* Клик по имени пользователя открывает карточку
|
||||
* профиля (`AdminUserProfileDialog`) — те же поля, что в собственном
|
||||
* профиле, плюс существующие действия роль/блокировка.
|
||||
*/
|
||||
export function AdminUsersTab() {
|
||||
const { user: currentUser } = useAuth()
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [viewingUserId, setViewingUserId] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setSearch(searchInput.trim())
|
||||
setOffset(0)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchInput])
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'users', search, offset],
|
||||
queryFn: () => listAdminUsers({ q: search || undefined, limit: PAGE_SIZE, offset }),
|
||||
})
|
||||
|
||||
const { data: teamsData } = useQuery({
|
||||
queryKey: ['admin', 'teams', 'all'],
|
||||
queryFn: () => listAdminTeams({ limit: TEAMS_LIMIT, offset: 0 }),
|
||||
})
|
||||
const teams = teamsData?.items ?? []
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: Parameters<typeof updateAdminUser>[1] }) => updateAdminUser(id, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
toast.show('Нельзя изменить собственную учётную запись', 'error')
|
||||
} else {
|
||||
toast.show('Не удалось сохранить изменения', 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function handleRoleChange(target: AdminUserOut, role: 'admin' | 'user') {
|
||||
if (role === target.role) return
|
||||
mutation.mutate({ id: target.id, payload: { role } })
|
||||
}
|
||||
|
||||
function handleToggleBlock(target: AdminUserOut) {
|
||||
mutation.mutate({ id: target.id, payload: { is_blocked: !target.is_blocked } })
|
||||
}
|
||||
|
||||
function handleTeamChange(target: AdminUserOut, teamId: string) {
|
||||
const nextTeamId = teamId === '' ? null : teamId
|
||||
if (nextTeamId === target.team_id) return
|
||||
mutation.mutate({ id: target.id, payload: { team_id: nextTeamId } })
|
||||
}
|
||||
|
||||
const items = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar-row">
|
||||
<div className="toolbar-left">
|
||||
<div className="search-wrap">
|
||||
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по имени или email…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||
<UserPlus style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
Добавить пользователя
|
||||
</button>
|
||||
</div>
|
||||
<span className="toolbar-count">
|
||||
{isLoading ? 'Загрузка…' : `Показано ${items.length ? offset + 1 : 0}–${offset + items.length} из ${total}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Пользователь</th>
|
||||
<th>Роль</th>
|
||||
<th>Команда</th>
|
||||
<th>Регистрация</th>
|
||||
<th>Статус</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 && !isLoading && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-ink-500)' }}>
|
||||
Пользователи не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.map((u) => {
|
||||
const isSelf = u.id === currentUser?.id
|
||||
return (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<Avatar name={u.name_user} avatarUrl={u.avatar_url} size={32} />
|
||||
<div>
|
||||
<button type="button" className="user-cell-name" onClick={() => setViewingUserId(u.id)}>
|
||||
{u.name_user}
|
||||
</button>
|
||||
<span>{u.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
value={u.role}
|
||||
disabled={isSelf || mutation.isPending}
|
||||
onChange={(e) => handleRoleChange(u, e.target.value as 'admin' | 'user')}
|
||||
aria-label="Роль пользователя"
|
||||
>
|
||||
<option value="user">Пользователь</option>
|
||||
<option value="admin">Администратор</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
value={u.team_id ?? ''}
|
||||
disabled={mutation.isPending}
|
||||
onChange={(e) => handleTeamChange(u, e.target.value)}
|
||||
aria-label="Команда пользователя"
|
||||
>
|
||||
<option value="">Без команды</option>
|
||||
{teams.map((team) => (
|
||||
<option key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>{new Date(u.created_at).toLocaleDateString('ru-RU')}</td>
|
||||
<td>
|
||||
{u.is_blocked ? (
|
||||
<span className="badge badge-busy">
|
||||
<Ban style={{ width: 12, height: 12 }} aria-hidden="true" />
|
||||
Заблокирован
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge badge-free">
|
||||
<CheckCircle2 style={{ width: 12, height: 12 }} aria-hidden="true" />
|
||||
Активен
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`icon-btn${u.is_blocked ? '' : ' danger'}`}
|
||||
aria-label={u.is_blocked ? 'Разблокировать' : 'Заблокировать'}
|
||||
disabled={isSelf || mutation.isPending}
|
||||
title={isSelf ? 'Нельзя изменить собственную учётную запись' : undefined}
|
||||
onClick={() => handleToggleBlock(u)}
|
||||
>
|
||||
{u.is_blocked ? (
|
||||
<CheckCircle2 style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
) : (
|
||||
<Ban style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="pagination-row">
|
||||
<button type="button" className="btn btn-secondary" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={offset + PAGE_SIZE >= total}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
>
|
||||
Далее
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewingUserId && <AdminUserProfileDialog userId={viewingUserId} onClose={() => setViewingUserId(null)} />}
|
||||
{creating && <AdminUserCreateDialog onClose={() => setCreating(false)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
51
frontend/src/components/auth/AuthLayout.tsx
Normal file
51
frontend/src/components/auth/AuthLayout.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { LogoMark } from '@/components/ui/LogoMark'
|
||||
import { ThemeToggle } from '@/components/ui/ThemeToggle'
|
||||
import '@/styles/auth.css'
|
||||
|
||||
/**
|
||||
* Общая двухколоночная разметка экранов auth (бренд-панель + форма),
|
||||
* см. design/mockups/auth.html. Переключатель темы — плавающей плашкой в
|
||||
* правом верхнем углу (см. design/mockups/dark/login.html): у экрана нет
|
||||
* общего топбара, панель бренда и форма — раздельные колонки.
|
||||
*/
|
||||
export function AuthLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<ThemeToggle className="theme-toggle--floating" />
|
||||
|
||||
<aside className="brand-panel">
|
||||
<div className="brand-mark">
|
||||
<LogoMark /> VidConf
|
||||
</div>
|
||||
<div className="brand-copy">
|
||||
<p className="eyebrow">[ SELF-HOSTED ВИДЕОКОНФЕРЕНЦИИ ]</p>
|
||||
<h1 className="brand-headline">
|
||||
Ваши встречи.
|
||||
<br />
|
||||
Ваша инфраструктура.
|
||||
</h1>
|
||||
<p className="brand-sub">
|
||||
Видео, записи и AI-саммари обрабатываются на ваших серверах - данные не уходят за пределы вашей
|
||||
инфраструктуры. Гости подключаются по ссылке или номеру, без регистрации.
|
||||
</p>
|
||||
<div className="brand-stats">
|
||||
<div className="stat-glass">
|
||||
<strong>100%</strong>
|
||||
<span>данных - в вашем контуре</span>
|
||||
</div>
|
||||
<div className="stat-glass">
|
||||
<strong>AI-саммари</strong>
|
||||
<span>на почту после встречи</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="hint-msg" style={{ position: 'relative', zIndex: 1 }}>
|
||||
© 2026 VidConf · self-hosted
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<main className="form-panel">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
245
frontend/src/components/calendar/ConferenceCalendar.tsx
Normal file
245
frontend/src/components/calendar/ConferenceCalendar.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import FullCalendar from '@fullcalendar/react'
|
||||
import type { DatesSetArg, EventContentArg, EventInput, EventMountArg } from '@fullcalendar/core'
|
||||
import type { EventClickArg, EventHoveringArg } from '@fullcalendar/core'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import ruLocale from '@fullcalendar/core/locales/ru'
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Lock, Play, Repeat } from 'lucide-react'
|
||||
import type { OccurrenceOut } from '@/api/conferences'
|
||||
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
|
||||
|
||||
/*
|
||||
* Стабильные (модульные, не пересоздаваемые на каждый рендер) значения для
|
||||
* пропсов FullCalendar. `@fullcalendar/react` при любом изменении identity
|
||||
* пропса-опции (массива/объекта/функции) дёргает внутренний `resetOptions`,
|
||||
* который заново вызывает `datesSet` — если тот, в свою очередь, меняет
|
||||
* состояние компонента без проверки «а изменилось ли значение», получается
|
||||
* бесконечный цикл setState → рендер → новые identity пропсов → resetOptions
|
||||
* → datesSet → setState (баг гейта, 2026-07-16: /calendar рендерился пустым
|
||||
* экраном с «Maximum update depth exceeded» в консоли). Поэтому: массивы
|
||||
* `plugins`/`hiddenDays` и функция `renderEventContent` — константы модуля
|
||||
* (не зависят от пропсов/состояния), а `events`/колбэки, зависящие от
|
||||
* пропсов, — обёрнуты в `useMemo`/`useCallback` ниже.
|
||||
*/
|
||||
const CALENDAR_PLUGINS = [dayGridPlugin, interactionPlugin]
|
||||
const HIDDEN_DAYS_BUSINESS_WEEK: number[] = [0, 6]
|
||||
const HIDDEN_DAYS_NONE: number[] = []
|
||||
|
||||
/**
|
||||
* Хранилище отписок focusin/focusout по DOM-элементу чипа:
|
||||
* ховер-карточка по клавиатурному фокусу) — элементы событий FullCalendar
|
||||
* рендерятся вне React-дерева, поэтому слушатели вешаются нативно в
|
||||
* `eventDidMount`/снимаются в `eventWillUnmount`; `WeakMap` не мешает сборке
|
||||
* мусора при повторном рендере событий.
|
||||
*/
|
||||
const eventFocusCleanups = new WeakMap<HTMLElement, () => void>()
|
||||
|
||||
export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth'
|
||||
|
||||
interface ConferenceCalendarProps {
|
||||
view: CalendarViewMode
|
||||
onViewChange: (view: CalendarViewMode) => void
|
||||
occurrences: OccurrenceOut[]
|
||||
isLoading: boolean
|
||||
/** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */
|
||||
onRangeChange: (fromIso: string, toIso: string) => void
|
||||
onOccurrenceClick: (occurrence: OccurrenceOut) => void
|
||||
}
|
||||
|
||||
type BadgeKind = 'scheduled' | 'pinned' | 'live' | 'ended'
|
||||
|
||||
function occurrenceBadge(occ: OccurrenceOut, start: Date | null, end: Date | null): { kind: BadgeKind; label: string } {
|
||||
const now = new Date()
|
||||
if (start && end && now >= start && now <= end) return { kind: 'live', label: 'Идёт сейчас' }
|
||||
if (end && now > end) return { kind: 'ended', label: 'Завершена' }
|
||||
if (occ.is_pinned) return { kind: 'pinned', label: 'Закреплена' }
|
||||
return { kind: 'scheduled', label: 'Запланирована' }
|
||||
}
|
||||
|
||||
const BADGE_ICON: Record<BadgeKind, typeof Clock> = {
|
||||
scheduled: Clock,
|
||||
pinned: Repeat,
|
||||
live: Play,
|
||||
ended: CheckCircle2,
|
||||
}
|
||||
|
||||
/** Не зависит от пропсов/состояния компонента — модульная константа-функция (см. комментарий выше про стабильность identity). */
|
||||
function renderEventContent(arg: EventContentArg) {
|
||||
const occ = arg.event.extendedProps.occurrence as OccurrenceOut
|
||||
const start = arg.event.start
|
||||
const end = arg.event.end
|
||||
const badge = occurrenceBadge(occ, start, end)
|
||||
const Icon = BADGE_ICON[badge.kind]
|
||||
const timeLabel =
|
||||
start && end
|
||||
? `${start.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}–${end.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`
|
||||
: ''
|
||||
return (
|
||||
<div>
|
||||
<span className={`conf-badge ${badge.kind}`}>
|
||||
<Icon style={{ width: 11, height: 11 }} aria-hidden="true" />
|
||||
{badge.label}
|
||||
</span>
|
||||
<span className="chip-time">{timeLabel}</span>
|
||||
<span className="chip-title">
|
||||
{occ.is_closed && <Lock className="chip-lock" aria-hidden="true" />}
|
||||
{arg.event.title}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обёртка над FullCalendar (см. design/mockups/calendar.html): недельная
|
||||
* (только будни, 5 колонок) / месячная сетка вхождений конференций, свой
|
||||
* тулбар (вид, навигация по датам) вместо встроенного headerToolbar.
|
||||
* События рендерятся как «чипы» (`.conf-chip`) со статус-бейджем §4.6
|
||||
* DESIGN_SYSTEM.md — вместо позиционирования по времени (timeGrid), т.к.
|
||||
* менеджер конференций показывает список вхождений по дням, не занятость
|
||||
* переговорной по часам (переговорных комнат в модели нет).
|
||||
*/
|
||||
export function ConferenceCalendar({
|
||||
view,
|
||||
onViewChange,
|
||||
occurrences,
|
||||
isLoading,
|
||||
onRangeChange,
|
||||
onOccurrenceClick,
|
||||
}: ConferenceCalendarProps) {
|
||||
const calendarRef = useRef<FullCalendar | null>(null)
|
||||
const titleRef = useRef<HTMLSpanElement | null>(null)
|
||||
|
||||
// Ховер-карточка конференции — состояние держим внутри
|
||||
// компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже
|
||||
// оставались стабильными по identity между рендерами (см. комментарий в
|
||||
// начале файла про resetOptions/бесконечный цикл при нестабильных пропсах).
|
||||
const [hoverTarget, setHoverTarget] = useState<{ occurrence: OccurrenceOut; anchorEl: HTMLElement } | null>(null)
|
||||
|
||||
const handleEventMouseEnter = useCallback((arg: EventHoveringArg) => {
|
||||
setHoverTarget({ occurrence: arg.event.extendedProps.occurrence as OccurrenceOut, anchorEl: arg.el })
|
||||
}, [])
|
||||
|
||||
const handleEventMouseLeave = useCallback((arg: EventHoveringArg) => {
|
||||
setHoverTarget((prev) => (prev && prev.anchorEl === arg.el ? null : prev))
|
||||
}, [])
|
||||
|
||||
// Клавиатурная доступность ховер-карточки: чипы FullCalendar рендерятся
|
||||
// самой библиотекой вне React-дерева, поэтому фокус отслеживаем нативными
|
||||
// слушателями focusin/focusout (не React onFocus/onBlur).
|
||||
const handleEventDidMount = useCallback((arg: EventMountArg) => {
|
||||
arg.el.tabIndex = 0
|
||||
const occurrence = arg.event.extendedProps.occurrence as OccurrenceOut
|
||||
const handleFocusIn = () => setHoverTarget({ occurrence, anchorEl: arg.el })
|
||||
const handleFocusOut = () => setHoverTarget((prev) => (prev && prev.anchorEl === arg.el ? null : prev))
|
||||
arg.el.addEventListener('focusin', handleFocusIn)
|
||||
arg.el.addEventListener('focusout', handleFocusOut)
|
||||
eventFocusCleanups.set(arg.el, () => {
|
||||
arg.el.removeEventListener('focusin', handleFocusIn)
|
||||
arg.el.removeEventListener('focusout', handleFocusOut)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleEventWillUnmount = useCallback((arg: EventMountArg) => {
|
||||
eventFocusCleanups.get(arg.el)?.()
|
||||
eventFocusCleanups.delete(arg.el)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
calendarRef.current?.getApi().changeView(view)
|
||||
}, [view])
|
||||
|
||||
function goPrev() {
|
||||
calendarRef.current?.getApi().prev()
|
||||
}
|
||||
function goNext() {
|
||||
calendarRef.current?.getApi().next()
|
||||
}
|
||||
function goToday() {
|
||||
calendarRef.current?.getApi().today()
|
||||
}
|
||||
|
||||
// Мемоизировано по `occurrences` — иначе на каждый рендер создавался бы
|
||||
// новый массив-`events` той же длины/содержимого, и FullCalendar считал бы
|
||||
// его «изменившейся опцией» (см. комментарий у модульных констант выше).
|
||||
const events: EventInput[] = useMemo(
|
||||
() =>
|
||||
occurrences.map((occ) => ({
|
||||
id: `${occ.conference_id}:${occ.starts_at}`,
|
||||
title: occ.title ?? 'Конференция без названия',
|
||||
start: occ.starts_at,
|
||||
end: occ.ends_at,
|
||||
classNames: ['conf-chip'],
|
||||
extendedProps: { occurrence: occ },
|
||||
})),
|
||||
[occurrences],
|
||||
)
|
||||
|
||||
const handleEventClick = useCallback(
|
||||
(info: EventClickArg) => {
|
||||
onOccurrenceClick(info.event.extendedProps.occurrence as OccurrenceOut)
|
||||
},
|
||||
[onOccurrenceClick],
|
||||
)
|
||||
|
||||
const handleDatesSet = useCallback(
|
||||
(arg: DatesSetArg) => {
|
||||
const api = calendarRef.current?.getApi()
|
||||
if (api && titleRef.current) titleRef.current.textContent = api.view.title
|
||||
onRangeChange(arg.start.toISOString(), arg.end.toISOString())
|
||||
},
|
||||
[onRangeChange],
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="week-card">
|
||||
<div className="week-toolbar">
|
||||
<div className="date-nav">
|
||||
<button type="button" className="date-nav-arrow" aria-label="Предыдущий период" onClick={goPrev}>
|
||||
<ChevronLeft className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
<span ref={titleRef} />
|
||||
<button type="button" className="date-nav-arrow" aria-label="Следующий период" onClick={goNext}>
|
||||
<ChevronRight className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={goToday}>
|
||||
Сегодня
|
||||
</button>
|
||||
</div>
|
||||
<div className="view-toggle" role="group" aria-label="Вид календаря">
|
||||
<button type="button" className={view === 'dayGridWeek' ? 'is-active' : ''} onClick={() => onViewChange('dayGridWeek')}>
|
||||
Неделя
|
||||
</button>
|
||||
<button type="button" className={view === 'dayGridMonth' ? 'is-active' : ''} onClick={() => onViewChange('dayGridMonth')}>
|
||||
Месяц
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vc-calendar" aria-busy={isLoading}>
|
||||
<FullCalendar
|
||||
ref={calendarRef}
|
||||
plugins={CALENDAR_PLUGINS}
|
||||
initialView={view}
|
||||
headerToolbar={false}
|
||||
locale={ruLocale}
|
||||
timeZone="local"
|
||||
firstDay={1}
|
||||
hiddenDays={view === 'dayGridWeek' ? HIDDEN_DAYS_BUSINESS_WEEK : HIDDEN_DAYS_NONE}
|
||||
height="auto"
|
||||
dayMaxEvents={false}
|
||||
events={events}
|
||||
eventContent={renderEventContent}
|
||||
eventClick={handleEventClick}
|
||||
datesSet={handleDatesSet}
|
||||
eventMouseEnter={handleEventMouseEnter}
|
||||
eventMouseLeave={handleEventMouseLeave}
|
||||
eventDidMount={handleEventDidMount}
|
||||
eventWillUnmount={handleEventWillUnmount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConferenceHoverCard conferenceId={hoverTarget?.occurrence.conference_id ?? null} anchorEl={hoverTarget?.anchorEl ?? null} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
529
frontend/src/components/calendar/ConferenceFormCard.tsx
Normal file
529
frontend/src/components/calendar/ConferenceFormCard.tsx
Normal file
@@ -0,0 +1,529 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle, Calendar as CalendarIcon, Check, Clock, Lock } from 'lucide-react'
|
||||
import {
|
||||
createConference,
|
||||
deleteConference,
|
||||
getConference,
|
||||
updateConference,
|
||||
type ConferenceCreatePayload,
|
||||
type ConferenceOut,
|
||||
type ConferenceRecurrence,
|
||||
type InviteeIn,
|
||||
type RecurrenceType,
|
||||
type SummaryRecipientsMode,
|
||||
} from '@/api/conferences'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { ParticipantsPicker, type SelectedParticipant } from '@/components/calendar/ParticipantsPicker'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
import { browserTimeZone, localDateTimeToIso, toLocalDateTimeParts } from '@/lib/localTime'
|
||||
import { WEEKDAY_SHORT, formatPreviewHint, previewNextDates } from '@/lib/recurrenceFormat'
|
||||
|
||||
const SUMMARY_RECIPIENTS_OPTIONS = [
|
||||
{ value: '', label: 'По умолчанию' },
|
||||
{ value: 'all', label: 'Всем участникам' },
|
||||
{ value: 'owner', label: 'Только организатору' },
|
||||
]
|
||||
|
||||
interface ConferenceFormCardProps {
|
||||
/** `undefined` — карточка в режиме создания; конференция — режим редактирования (см. design/mockups/calendar.html). */
|
||||
conference?: ConferenceOut
|
||||
onSaved: (conference: ConferenceOut) => void
|
||||
onDeleted?: () => void
|
||||
/** Только для режима редактирования — вернуться к пустой форме создания. */
|
||||
onCancelEdit?: () => void
|
||||
}
|
||||
|
||||
function defaultTimeSlot(): { date: string; time: string } {
|
||||
const now = new Date()
|
||||
now.setMinutes(0, 0, 0)
|
||||
now.setHours(now.getHours() + 1)
|
||||
return toLocalDateTimeParts(now.toISOString())
|
||||
}
|
||||
|
||||
function weekdayOf(dateStr: string): number {
|
||||
if (!dateStr) return 0
|
||||
const [y, m, d] = dateStr.split('-').map(Number)
|
||||
return (new Date(y, m - 1, d).getDay() + 6) % 7
|
||||
}
|
||||
|
||||
/**
|
||||
* Сайдбар-карточка создания/редактирования запланированной конференции —
|
||||
* 1:1 `.create-card` из design/mockups/calendar.html. В отличие от статичного
|
||||
* макета (раскрытие блоков через CSS `:has()`), состояние — управляемое
|
||||
* (`useState`), как и предписано DESIGN_SYSTEM.md §4.13 для frontend.
|
||||
*/
|
||||
export function ConferenceFormCard({ conference, onSaved, onDeleted, onCancelEdit }: ConferenceFormCardProps) {
|
||||
const isEdit = !!conference
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
const { user: currentUser } = useAuth()
|
||||
|
||||
// Не-организатору форма недоступна вообще (кнопки правки/удаления скрыты
|
||||
// везде выше по дереву), но проверяем и здесь как
|
||||
// защиту в глубину: список конференции (`/conferences/my`) уже содержит
|
||||
// `is_owner`, детальный запрос ниже не нужен для этой проверки.
|
||||
const isOwner = conference?.is_owner ?? true
|
||||
|
||||
const initialSlot = useMemo(
|
||||
() => (conference?.scheduled_at ? toLocalDateTimeParts(conference.scheduled_at) : defaultTimeSlot()),
|
||||
[conference],
|
||||
)
|
||||
|
||||
const [title, setTitle] = useState(conference?.title ?? '')
|
||||
const [dateStr, setDateStr] = useState(initialSlot.date)
|
||||
const [timeStr, setTimeStr] = useState(initialSlot.time)
|
||||
const [durationMinutes, setDurationMinutes] = useState(conference?.duration_minutes ?? 30)
|
||||
|
||||
const [isClosed, setIsClosed] = useState(conference?.is_closed ?? false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [summaryRecipients, setSummaryRecipients] = useState<'' | SummaryRecipientsMode>(
|
||||
conference?.summary_recipients ?? '',
|
||||
)
|
||||
|
||||
const [isPinned, setIsPinned] = useState(conference?.is_pinned ?? false)
|
||||
const [recurrenceType, setRecurrenceType] = useState<RecurrenceType>(conference?.recurrence?.type ?? 'weekly')
|
||||
const [weekdays, setWeekdays] = useState<number[]>(
|
||||
conference?.recurrence?.weekdays ?? (conference ? [] : [weekdayOf(initialSlot.date)]),
|
||||
)
|
||||
const [dayOfMonth, setDayOfMonth] = useState(conference?.recurrence?.day_of_month ?? 1)
|
||||
const [intervalDays, setIntervalDays] = useState(conference?.recurrence?.interval_days ?? 7)
|
||||
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
|
||||
// Участники. Списковый `/conferences/my` не содержит
|
||||
// `participants` (только детальный `GET /conferences/{id}`, owner|admin) —
|
||||
// при редактировании подгружаем состав отдельным запросом.
|
||||
const [participants, setParticipants] = useState<SelectedParticipant[]>([])
|
||||
const [participantsTouched, setParticipantsTouched] = useState(false)
|
||||
|
||||
const participantsQuery = useQuery({
|
||||
queryKey: ['conferences', 'detail', conference?.id],
|
||||
queryFn: () => getConference(conference!.id),
|
||||
enabled: isEdit,
|
||||
})
|
||||
|
||||
// Сидируем состояние формы из загруженной детали конференции — паттерн
|
||||
// «подстройка состояния при изменении пропса/данных запроса во время
|
||||
// рендера» (react.dev/learn/you-might-not-need-an-effect), а не эффект:
|
||||
// setState вызывается один раз для каждого нового объекта `participants`
|
||||
// из query (`loadedParticipants` меняет identity только на новый ответ),
|
||||
// не на каждый ре-рендер.
|
||||
const loadedParticipants = participantsQuery.data?.participants
|
||||
const [seededFrom, setSeededFrom] = useState<typeof loadedParticipants>(undefined)
|
||||
if (isEdit && loadedParticipants && loadedParticipants !== seededFrom) {
|
||||
setSeededFrom(loadedParticipants)
|
||||
setParticipants(
|
||||
loadedParticipants
|
||||
.filter((p) => !p.is_organizer)
|
||||
.map((p) => ({
|
||||
key: p.user_id ?? p.email,
|
||||
userId: p.user_id ?? undefined,
|
||||
email: p.email,
|
||||
displayName: p.name ?? p.email,
|
||||
avatarUrl: p.avatar_url,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
const organizerChip = isEdit
|
||||
? conference?.organizer_name
|
||||
? { name: conference.organizer_name, avatarUrl: participantsQuery.data?.participants?.find((p) => p.is_organizer)?.avatar_url ?? null }
|
||||
: null
|
||||
: currentUser
|
||||
? { name: currentUser.name_user, email: currentUser.email, avatarUrl: currentUser.avatar_url }
|
||||
: null
|
||||
|
||||
function invalidateConferenceQueries() {
|
||||
queryClient.invalidateQueries({ queryKey: ['conferences'] })
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (payload: ConferenceCreatePayload) =>
|
||||
isEdit ? updateConference(conference!.id, payload) : createConference(payload),
|
||||
onSuccess: (result) => {
|
||||
invalidateConferenceQueries()
|
||||
toast.show(isEdit ? 'Изменения сохранены' : 'Конференция запланирована', 'success')
|
||||
onSaved(result)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 422) {
|
||||
setFormError('Проверьте правильность заполнения полей')
|
||||
} else {
|
||||
setFormError(isEdit ? 'Не удалось сохранить изменения. Попробуйте ещё раз' : 'Не удалось создать конференцию. Попробуйте ещё раз')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const unpinMutation = useMutation({
|
||||
mutationFn: () => updateConference(conference!.id, { is_pinned: false }),
|
||||
onSuccess: (result) => {
|
||||
invalidateConferenceQueries()
|
||||
toast.show('Конференция откреплена', 'success')
|
||||
onSaved(result)
|
||||
},
|
||||
onError: () => toast.show('Не удалось открепить конференцию', 'error'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteConference(conference!.id),
|
||||
onSuccess: () => {
|
||||
invalidateConferenceQueries()
|
||||
toast.show('Конференция удалена', 'success')
|
||||
onDeleted?.()
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
toast.show('Нельзя удалить — конференция сейчас идёт', 'error')
|
||||
} else {
|
||||
toast.show('Не удалось удалить конференцию', 'error')
|
||||
}
|
||||
setConfirmingDelete(false)
|
||||
},
|
||||
})
|
||||
|
||||
function toggleWeekday(day: number) {
|
||||
setWeekdays((prev) => (prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day].sort((a, b) => a - b)))
|
||||
}
|
||||
|
||||
const isRecurrenceValid =
|
||||
!isPinned ||
|
||||
(recurrenceType === 'weekly' && weekdays.length > 0) ||
|
||||
(recurrenceType === 'biweekly' && weekdays.length > 0) ||
|
||||
(recurrenceType === 'monthly' && dayOfMonth >= 1 && dayOfMonth <= 31) ||
|
||||
(recurrenceType === 'every_n_days' && intervalDays >= 1)
|
||||
const isPasswordValid = !isClosed || password.length >= 4
|
||||
const canSubmit = !!dateStr && !!timeStr && durationMinutes > 0 && isPasswordValid && isRecurrenceValid && !saveMutation.isPending
|
||||
|
||||
const previewHint = useMemo(() => {
|
||||
if (!isPinned) return ''
|
||||
const dates = previewNextDates(
|
||||
{ type: recurrenceType, weekdays, day_of_month: dayOfMonth, interval_days: intervalDays, anchor_date: dateStr || initialSlot.date },
|
||||
2,
|
||||
)
|
||||
return formatPreviewHint(dates)
|
||||
}, [isPinned, recurrenceType, weekdays, dayOfMonth, intervalDays, dateStr, initialSlot.date])
|
||||
|
||||
function handleSubmit() {
|
||||
setFormError(null)
|
||||
const recurrence: ConferenceRecurrence | undefined = isPinned
|
||||
? {
|
||||
type: recurrenceType,
|
||||
weekdays: recurrenceType === 'weekly' || recurrenceType === 'biweekly' ? weekdays : undefined,
|
||||
day_of_month: recurrenceType === 'monthly' ? dayOfMonth : undefined,
|
||||
interval_days: recurrenceType === 'every_n_days' ? intervalDays : undefined,
|
||||
anchor_date: dateStr,
|
||||
time_local: timeStr,
|
||||
timezone: browserTimeZone(),
|
||||
duration_minutes: durationMinutes,
|
||||
}
|
||||
: undefined
|
||||
// Состав участников отправляем только если его трогали — иначе поле не
|
||||
// передаём вовсе (не отправляем `participants`), чтобы backend не
|
||||
// заменил состав пустым списком (см. контракт `InviteeIn`). `p.email!` —
|
||||
// безопасно: внешний участник (без `userId`) в UI добавляется только
|
||||
// через `addExternal`, которая всегда заполняет `email` (ParticipantsPicker.tsx).
|
||||
const participantsPayload: InviteeIn[] | undefined = participantsTouched
|
||||
? participants.map((p) => (p.userId ? { user_id: p.userId } : { email: p.email! }))
|
||||
: undefined
|
||||
|
||||
saveMutation.mutate({
|
||||
title: title.trim() || undefined,
|
||||
scheduled_at: localDateTimeToIso(dateStr, timeStr),
|
||||
duration_minutes: durationMinutes,
|
||||
is_pinned: isPinned,
|
||||
recurrence,
|
||||
is_closed: isClosed,
|
||||
password: isClosed ? password : undefined,
|
||||
summary_recipients: summaryRecipients === '' ? null : summaryRecipients,
|
||||
participants: participantsPayload,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="create-card">
|
||||
<h2>
|
||||
<CalendarIcon className="lucide" style={{ width: 20, height: 20 }} aria-hidden="true" />
|
||||
{isEdit ? 'Редактирование конференции' : 'Новая конференция'}
|
||||
</h2>
|
||||
|
||||
{isEdit && conference?.next_occurrence && (
|
||||
<div className="next-occurrence">
|
||||
<Clock style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
Следующее вхождение: {new Date(conference.next_occurrence).toLocaleString('ru-RU', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
<div className="error-banner">
|
||||
<AlertTriangle style={{ width: 18, height: 18 }} aria-hidden="true" />
|
||||
<div>{formError}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}}
|
||||
>
|
||||
<div className="field">
|
||||
<label htmlFor="conf-title">Название</label>
|
||||
<input
|
||||
id="conf-title"
|
||||
type="text"
|
||||
placeholder="Например, Синк по релизу 2.4"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label htmlFor="conf-date">Дата</label>
|
||||
<input id="conf-date" type="date" value={dateStr} onChange={(e) => setDateStr(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="conf-time">Время</label>
|
||||
<input id="conf-time" type="time" value={timeStr} onChange={(e) => setTimeStr(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="conf-duration">Длительность, мин</label>
|
||||
<input
|
||||
id="conf-duration"
|
||||
type="number"
|
||||
min={5}
|
||||
step={5}
|
||||
value={durationMinutes}
|
||||
onChange={(e) => setDurationMinutes(Number(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label id="conf-summary-recipients-label" htmlFor="conf-summary-recipients">
|
||||
Рассылка саммари
|
||||
</label>
|
||||
<Select
|
||||
id="conf-summary-recipients"
|
||||
aria-labelledby="conf-summary-recipients-label"
|
||||
value={summaryRecipients}
|
||||
onChange={(v) => setSummaryRecipients(v as '' | SummaryRecipientsMode)}
|
||||
options={SUMMARY_RECIPIENTS_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label id="conf-participants-label">Участники</label>
|
||||
<ParticipantsPicker
|
||||
participants={participants}
|
||||
onChange={(next) => {
|
||||
setParticipants(next)
|
||||
setParticipantsTouched(true)
|
||||
}}
|
||||
organizer={organizerChip}
|
||||
disabled={isEdit && participantsQuery.isPending}
|
||||
/>
|
||||
{isEdit && participantsQuery.isPending && <p className="field-hint">Загрузка состава участников…</p>}
|
||||
</div>
|
||||
|
||||
<div className="toggle-block">
|
||||
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
|
||||
<div className="toggle-copy">
|
||||
<strong>Закрытая по паролю</strong>
|
||||
<span>Вход только по паролю, включая гостей</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={isClosed} onChange={(e) => setIsClosed(e.target.checked)} />
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
{isClosed && (
|
||||
<div className="password-block field">
|
||||
<label htmlFor="conf-password">
|
||||
<Lock style={{ width: 14, height: 14 }} aria-hidden="true" />
|
||||
Пароль конференции
|
||||
</label>
|
||||
<input
|
||||
id="conf-password"
|
||||
type="password"
|
||||
placeholder="Минимум 4 символа"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pin-block">
|
||||
<div className="toggle-row">
|
||||
<div className="toggle-copy">
|
||||
<strong>{isEdit ? 'Закреплена' : 'Закрепить постоянную конференцию'}</strong>
|
||||
<span>Сохранится и попадёт в «Мои конференции», не исчезнет после встречи</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={isPinned} onChange={(e) => setIsPinned(e.target.checked)} />
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isPinned && (
|
||||
<div className="recurrence-panel">
|
||||
<label className="recurrence-option-row">
|
||||
<input type="radio" name="recurrence-type" checked={recurrenceType === 'weekly'} onChange={() => setRecurrenceType('weekly')} />
|
||||
По дням недели, еженедельно
|
||||
</label>
|
||||
{recurrenceType === 'weekly' && (
|
||||
<div className="recurrence-sub">
|
||||
<WeekdayToggles selected={weekdays} onToggle={toggleWeekday} />
|
||||
{previewHint && <p className="recurrence-hint">{previewHint}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="recurrence-option-row">
|
||||
<input type="radio" name="recurrence-type" checked={recurrenceType === 'biweekly'} onChange={() => setRecurrenceType('biweekly')} />
|
||||
Раз в 2 недели
|
||||
</label>
|
||||
{recurrenceType === 'biweekly' && (
|
||||
<div className="recurrence-sub">
|
||||
<WeekdayToggles selected={weekdays} onToggle={toggleWeekday} />
|
||||
{previewHint && <p className="recurrence-hint">{previewHint}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="recurrence-option-row">
|
||||
<input type="radio" name="recurrence-type" checked={recurrenceType === 'monthly'} onChange={() => setRecurrenceType('monthly')} />
|
||||
Раз в месяц
|
||||
</label>
|
||||
{recurrenceType === 'monthly' && (
|
||||
<div className="recurrence-sub">
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label htmlFor="conf-day-of-month">Число месяца</label>
|
||||
<input
|
||||
id="conf-day-of-month"
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
value={dayOfMonth}
|
||||
onChange={(e) => setDayOfMonth(Number(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
<p className="recurrence-hint">
|
||||
Если в месяце нет такого числа — переносится на последний день месяца (напр. 28/29 февраля)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="recurrence-option-row">
|
||||
<input
|
||||
type="radio"
|
||||
name="recurrence-type"
|
||||
checked={recurrenceType === 'every_n_days'}
|
||||
onChange={() => setRecurrenceType('every_n_days')}
|
||||
/>
|
||||
Каждые N дней
|
||||
</label>
|
||||
{recurrenceType === 'every_n_days' && (
|
||||
<div className="recurrence-sub">
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label htmlFor="conf-interval-days">N (дней)</label>
|
||||
<input
|
||||
id="conf-interval-days"
|
||||
type="number"
|
||||
min={1}
|
||||
value={intervalDays}
|
||||
onChange={(e) => setIntervalDays(Number(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
{previewHint && <p className="recurrence-hint">{previewHint}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isOwner ? (
|
||||
<p className="field-hint" style={{ marginTop: 'var(--space-3)' }}>
|
||||
Редактирование доступно только организатору конференции.
|
||||
</p>
|
||||
) : isEdit && conference?.is_pinned ? (
|
||||
<div className="btn-row">
|
||||
<button type="submit" className="btn btn-primary" disabled={!canSubmit}>
|
||||
{saveMutation.isPending ? 'Сохраняем…' : 'Сохранить изменения'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => unpinMutation.mutate()} disabled={unpinMutation.isPending}>
|
||||
Открепить
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="submit" className="btn btn-primary full" disabled={!canSubmit}>
|
||||
{saveMutation.isPending ? 'Сохраняем…' : isEdit ? 'Сохранить изменения' : 'Создать конференцию'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{isEdit && (
|
||||
<>
|
||||
{isOwner && (
|
||||
<div className="danger-link-row">
|
||||
{confirmingDelete ? (
|
||||
<div className="error-banner" style={{ alignItems: 'center', marginTop: 'var(--space-4)' }}>
|
||||
<AlertTriangle style={{ width: 18, height: 18 }} aria-hidden="true" />
|
||||
<div style={{ flex: 1, textAlign: 'left' }}>Удалить конференцию навсегда? Действие необратимо.</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
style={{ width: 'auto', padding: '6px 14px' }}
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
>
|
||||
<Check style={{ width: 14, height: 14 }} aria-hidden="true" />
|
||||
Да, удалить
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className="btn-text" onClick={() => setConfirmingDelete(true)}>
|
||||
Удалить конференцию навсегда
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="danger-link-row">
|
||||
<button type="button" className="btn-text" style={{ color: 'var(--color-ink-500)' }} onClick={onCancelEdit}>
|
||||
Отмена редактирования
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function WeekdayToggles({ selected, onToggle }: { selected: number[]; onToggle: (day: number) => void }) {
|
||||
return (
|
||||
<div className="weekday-toggles">
|
||||
{WEEKDAY_SHORT.map((label, day) => (
|
||||
<button
|
||||
type="button"
|
||||
key={label}
|
||||
className={`weekday-chip${selected.includes(day) ? ' is-selected' : ''}`}
|
||||
onClick={() => onToggle(day)}
|
||||
aria-pressed={selected.includes(day)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Calendar, Lock, Play, Repeat, X } from 'lucide-react'
|
||||
import type { OccurrenceOut } from '@/api/conferences'
|
||||
|
||||
interface ConferenceOccurrenceDialogProps {
|
||||
occurrence: OccurrenceOut
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function formatRange(startsAt: string, endsAt: string): string {
|
||||
const start = new Date(startsAt)
|
||||
const end = new Date(endsAt)
|
||||
const dateLabel = start.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
const startTime = start.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||
const endTime = end.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||
return `${dateLabel}, ${startTime}–${endTime}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Быстрый просмотр вхождения конференции по клику на чип в календаре — для
|
||||
* конференций, которые пользователь не организует (не владелец —
|
||||
* `is_owner`): свои конференции CalendarPage открывает сразу в форме
|
||||
* редактирования (`ConferenceFormCard`). Здесь — минимум: когда, статус, вход
|
||||
* и ссылка на управление; подробности состава участников/организатора — в
|
||||
* ховер-карточке при наведении/фокусе на чип (`ConferenceHoverCard`).
|
||||
*/
|
||||
export function ConferenceOccurrenceDialog({ occurrence, onClose }: ConferenceOccurrenceDialogProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="occurrence-dialog-title" onClick={onClose}>
|
||||
<div className="modal-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<h2 id="occurrence-dialog-title">
|
||||
{occurrence.is_closed && <Lock className="title-lock" aria-hidden="true" />}
|
||||
{occurrence.title ?? 'Конференция без названия'}
|
||||
</h2>
|
||||
<button type="button" className="modal-close" aria-label="Закрыть" onClick={onClose}>
|
||||
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="detail-row">
|
||||
<Calendar style={{ width: 18, height: 18 }} aria-hidden="true" />
|
||||
<span className="label">Когда</span>
|
||||
<span>{formatRange(occurrence.starts_at, occurrence.ends_at)}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<Repeat style={{ width: 18, height: 18 }} aria-hidden="true" />
|
||||
<span className="label">Номер</span>
|
||||
<span>{occurrence.number}</span>
|
||||
</div>
|
||||
{occurrence.is_pinned && (
|
||||
<div className="detail-row">
|
||||
<Repeat style={{ width: 18, height: 18 }} aria-hidden="true" />
|
||||
<span className="label">Статус</span>
|
||||
<span>Закреплённая конференция — повторяется по расписанию</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => navigate(`/j/${occurrence.slug}`)}>
|
||||
<Play style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
Войти
|
||||
</button>
|
||||
</div>
|
||||
<p className="field-hint" style={{ textAlign: 'center', marginTop: 'var(--space-3)' }}>
|
||||
Управление и редактирование — в <Link to="/my-conferences">«Моих конференциях»</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
184
frontend/src/components/calendar/ParticipantsPicker.tsx
Normal file
184
frontend/src/components/calendar/ParticipantsPicker.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Search, UserPlus, X } from 'lucide-react'
|
||||
import { searchUsers } from '@/api/users'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
|
||||
/** Простая проверка формата email — не заменяет валидацию backend, только клиентская подсказка. */
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
/**
|
||||
* Выбранный участник конференции — зарегистрированный (`userId` заполнен,
|
||||
* `email` для него не всегда известен: `GET /users?q=` не возвращает email,
|
||||
* только `{id, display_name, avatar_url}`) или внешний гость (только `email`,
|
||||
* `userId` не заполнен).
|
||||
*/
|
||||
export interface SelectedParticipant {
|
||||
/** Стабильный идентификатор чипа — `userId` для зарегистрированных, email для внешних. */
|
||||
key: string
|
||||
userId?: string
|
||||
email?: string
|
||||
/** Что показывать в чипе/списке — имя для зарегистрированных, email для внешних. */
|
||||
displayName: string
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
interface OrganizerChip {
|
||||
name: string
|
||||
email?: string
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
interface ParticipantsPickerProps {
|
||||
participants: SelectedParticipant[]
|
||||
onChange: (next: SelectedParticipant[]) => void
|
||||
/** Организатор — показывается первым чипом без крестика удаления (не входит в редактируемый список). */
|
||||
organizer?: OrganizerChip | null
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Пикер участников конференции — новый UI-элемент, для
|
||||
* которого нет готового макета в `design/mockups/`; собран из уже
|
||||
* утверждённых паттернов дизайн-системы: чипы как у фильтров «Моих
|
||||
* конференций» (`.chip`), выпадающий список результатов — паттерн дроплиста
|
||||
* селекта (§4.16, `.vc-select-panel`/`.vc-select-option`), поле поиска — как
|
||||
* `.search-wrap` админки. Поиск зарегистрированных — debounce 300 мс; если
|
||||
* введённая строка похожа на email — параллельно предлагается добавить её
|
||||
* как внешнего участника (backend не отдаёт email по поиску, поэтому нельзя
|
||||
* достоверно исключить совпадение с уже найденным зарегистрированным).
|
||||
*/
|
||||
export function ParticipantsPicker({ participants, onChange, organizer, disabled }: ParticipantsPickerProps) {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(inputValue.trim()), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [inputValue])
|
||||
|
||||
const { data: results, isFetching } = useQuery({
|
||||
queryKey: ['users', 'search', debounced],
|
||||
queryFn: () => searchUsers(debounced, 8),
|
||||
enabled: debounced.length >= 2,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [open])
|
||||
|
||||
const selectedUserIds = new Set(participants.map((p) => p.userId).filter(Boolean))
|
||||
const selectedEmails = new Set(participants.map((p) => p.email?.toLowerCase()).filter(Boolean))
|
||||
const candidates = (results ?? []).filter((u) => !selectedUserIds.has(u.id))
|
||||
|
||||
const trimmed = inputValue.trim()
|
||||
const isEmailLike = EMAIL_RE.test(trimmed)
|
||||
const canAddExternal = isEmailLike && !selectedEmails.has(trimmed.toLowerCase())
|
||||
|
||||
function addRegistered(user: { id: string; display_name: string; avatar_url: string | null }) {
|
||||
onChange([...participants, { key: user.id, userId: user.id, displayName: user.display_name, avatarUrl: user.avatar_url }])
|
||||
setInputValue('')
|
||||
setDebounced('')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function addExternal(email: string) {
|
||||
onChange([...participants, { key: email.toLowerCase(), email, displayName: email }])
|
||||
setInputValue('')
|
||||
setDebounced('')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function removeParticipant(key: string) {
|
||||
onChange(participants.filter((p) => p.key !== key))
|
||||
}
|
||||
|
||||
const showPanel = open && debounced.length >= 2 && (candidates.length > 0 || canAddExternal || isFetching)
|
||||
|
||||
return (
|
||||
<div className="participants-picker" ref={rootRef}>
|
||||
{(organizer || participants.length > 0) && (
|
||||
<div className="participants-chips">
|
||||
{organizer && (
|
||||
<span className="participant-chip is-organizer">
|
||||
<Avatar name={organizer.name} avatarUrl={organizer.avatarUrl} size={20} />
|
||||
{organizer.name}
|
||||
<span className="participant-chip-role">организатор</span>
|
||||
</span>
|
||||
)}
|
||||
{participants.map((p) => (
|
||||
<span className="participant-chip" key={p.key}>
|
||||
<Avatar name={p.displayName} avatarUrl={p.avatarUrl} size={20} />
|
||||
{p.displayName}
|
||||
<button
|
||||
type="button"
|
||||
className="participant-chip-remove"
|
||||
aria-label={`Убрать участника: ${p.displayName}`}
|
||||
onClick={() => removeParticipant(p.key)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X style={{ width: 12, height: 12 }} aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="participants-search-wrap">
|
||||
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Имя, email или адрес внешнего гостя…"
|
||||
value={inputValue}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (candidates.length === 1) addRegistered(candidates[0])
|
||||
else if (canAddExternal) addExternal(trimmed)
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showPanel && (
|
||||
<ul className="vc-select-panel participants-results">
|
||||
{isFetching && candidates.length === 0 && <li className="participants-results-hint">Ищем…</li>}
|
||||
{candidates.map((user) => (
|
||||
<li key={user.id} className="vc-select-option" onClick={() => addRegistered(user)}>
|
||||
<span className="participant-option-body">
|
||||
<Avatar name={user.display_name} avatarUrl={user.avatar_url} size={22} />
|
||||
{user.display_name}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{canAddExternal && (
|
||||
<li className="vc-select-option" onClick={() => addExternal(trimmed)}>
|
||||
<span className="participant-option-body">
|
||||
<UserPlus style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
Добавить «{trimmed}» как внешнего участника
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{!isFetching && candidates.length === 0 && !canAddExternal && (
|
||||
<li className="participants-results-hint">Никого не нашли — введите email внешнего участника полностью</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
frontend/src/components/layout/ShellTopbar.tsx
Normal file
117
frontend/src/components/layout/ShellTopbar.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { CalendarDays, LayoutDashboard, LogOut, Menu, Users, Video } from 'lucide-react'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
import { LogoMark } from '@/components/ui/LogoMark'
|
||||
import { ThemeToggle } from '@/components/ui/ThemeToggle'
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ to: '/lobby', label: 'Лобби', icon: Video },
|
||||
{ to: '/calendar', label: 'Календарь', icon: CalendarDays },
|
||||
{ to: '/my-conferences', label: 'Мои конференции', icon: Users },
|
||||
]
|
||||
|
||||
/** Доступен только администраторам — сама админка тоже защищена route-guard'ом (см. App.tsx). */
|
||||
const ADMIN_NAV_LINK = { to: '/admin', label: 'Админка', icon: LayoutDashboard }
|
||||
|
||||
/**
|
||||
* Верхняя навигация светлой оболочки (лобби/календарь/мои конференции/админка),
|
||||
* см. design/mockups/lobby.html.
|
||||
*
|
||||
* Пункты навигации убраны в скрывающуюся панель за бургером (`.menu-wrap` +
|
||||
* `.burger` + `.menu`) — как в макете; список ссылок там расширен нашими
|
||||
* реальными разделами (в самом макете в панели — заглушки «Скачать
|
||||
* приложение»/«О сервисе», которых как отдельных экранов нет). Бургер виден
|
||||
* всегда, а не только на мобильной ширине — макет не прячет его по media
|
||||
* query ни на одной ширине, это единственный способ навигации между
|
||||
* разделами оболочки.
|
||||
*
|
||||
* Пилюля с ФИО теперь ссылка на страницу профиля
|
||||
* (`/profile`) — в макете это была декоративная заглушка без перехода;
|
||||
* аватар — общий компонент `Avatar` (фото или инициалы).
|
||||
*/
|
||||
export function ShellTopbar() {
|
||||
const { user, logout } = useAuth()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const menuWrapRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return
|
||||
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (menuWrapRef.current && !menuWrapRef.current.contains(event.target as Node)) {
|
||||
setMenuOpen(false)
|
||||
}
|
||||
}
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') setMenuOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
}, [menuOpen])
|
||||
|
||||
async function handleLogout() {
|
||||
setMenuOpen(false)
|
||||
await logout()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<Link to="/lobby" className="brand-mark">
|
||||
<LogoMark /> VidConf
|
||||
</Link>
|
||||
|
||||
<div className="topbar-right">
|
||||
<ThemeToggle />
|
||||
|
||||
<Link to="/profile" className="user-pill">
|
||||
<Avatar name={user?.name_user ?? '?'} avatarUrl={user?.avatar_url} />
|
||||
<span className="user-pill-name">{user?.name_user ?? '—'}</span>
|
||||
</Link>
|
||||
|
||||
<div className="menu-wrap" ref={menuWrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="burger"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="main-menu"
|
||||
aria-label={menuOpen ? 'Закрыть меню' : 'Открыть меню'}
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
>
|
||||
<Menu className="icon" aria-hidden="true" />
|
||||
</button>
|
||||
<nav className={`menu${menuOpen ? ' is-open' : ''}`} id="main-menu" aria-label="Основное меню">
|
||||
{[...NAV_LINKS, ...(user?.role === 'admin' ? [ADMIN_NAV_LINK] : [])].map((link) => {
|
||||
const Icon = link.icon
|
||||
return (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={location.pathname.startsWith(link.to) ? 'is-active' : ''}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<Icon className="icon" aria-hidden="true" />
|
||||
{link.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
<div className="menu-divider" />
|
||||
<button type="button" className="danger" onClick={handleLogout}>
|
||||
<LogOut className="icon" aria-hidden="true" />
|
||||
Выйти
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
120
frontend/src/components/room/ChatPanel.tsx
Normal file
120
frontend/src/components/room/ChatPanel.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
|
||||
import { AlertCircle, Send, X } from 'lucide-react'
|
||||
import type { ChatConnectionStatus, ChatMessageOut } from '@/hooks/useChat'
|
||||
import { formatLocalTime } from '@/lib/localTime'
|
||||
|
||||
interface ChatPanelProps {
|
||||
messages: ChatMessageOut[]
|
||||
status: ChatConnectionStatus
|
||||
statusMessage: string | null
|
||||
onSend: (text: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Панель чата комнаты конференции (см. design/mockups/room.html, блок
|
||||
* `.chat-panel`). Всегда в развёрнутом виде рендерится только пока сама
|
||||
* панель открыта — сворачивание/разворачивание и счётчик непрочитанных
|
||||
* управляются на уровне RoomPage (WS-соединение живёт независимо от того,
|
||||
* открыта ли панель, — иначе при сворачивании терялась бы история).
|
||||
*
|
||||
* Textarea, а не `<input>` из макета — сознательное отступление ради
|
||||
* Enter/Shift+Enter (перенос строки), стили сохранены визуально идентичными
|
||||
* пилюле-полю из макета.
|
||||
*/
|
||||
export function ChatPanel({ messages, status, statusMessage, onSend, onClose }: ChatPanelProps) {
|
||||
const [draft, setDraft] = useState('')
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Автоскролл к последнему сообщению — только если пользователь и так был
|
||||
// внизу списка, чтобы не мешать чтению прокрученной вверх истории.
|
||||
useEffect(() => {
|
||||
const el = listRef.current
|
||||
if (!el) return
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
|
||||
if (distanceFromBottom < 120) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
function handleSend() {
|
||||
const text = draft.trim()
|
||||
if (!text || status !== 'open') return
|
||||
onSend(text)
|
||||
setDraft('')
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
function handleDraftChange(event: ChangeEvent<HTMLTextAreaElement>) {
|
||||
setDraft(event.target.value)
|
||||
// Авторасширение textarea до 4 строк, дальше — внутренний скролл.
|
||||
const el = event.target
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="chat-panel">
|
||||
<div className="chat-head">
|
||||
<h2>Чат встречи</h2>
|
||||
<button type="button" aria-label="Свернуть чат" onClick={onClose}>
|
||||
<X className="lucide" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{statusMessage && (
|
||||
<p className="chat-status-banner">
|
||||
<AlertCircle className="lucide" aria-hidden="true" /> {statusMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="chat-messages" ref={listRef}>
|
||||
{messages.length === 0 && status === 'open' && !statusMessage && (
|
||||
<p className="chat-empty">Сообщений пока нет — начните обсуждение</p>
|
||||
)}
|
||||
{messages.map((message) => (
|
||||
<div className="msg" key={message.id}>
|
||||
<div className="msg-meta">
|
||||
<span className="msg-author">
|
||||
{message.author_name}
|
||||
{message.is_guest && <span className="msg-guest-badge">гость</span>}
|
||||
</span>
|
||||
<span className="msg-time">{formatLocalTime(message.created_at)}</span>
|
||||
</div>
|
||||
<div className="msg-bubble">{message.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="chat-input-row"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
handleSend()
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder="Написать сообщение…"
|
||||
value={draft}
|
||||
onChange={handleDraftChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={status !== 'open'}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button type="submit" aria-label="Отправить" disabled={!draft.trim() || status !== 'open'}>
|
||||
<Send className="lucide" aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
110
frontend/src/components/room/DeviceSettingsDialog.tsx
Normal file
110
frontend/src/components/room/DeviceSettingsDialog.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
|
||||
interface DeviceSettingsDialogProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */
|
||||
function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): string {
|
||||
return device.label || `${fallback} ${index + 1}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Диалог «Настройки устройств» — два селекта
|
||||
* на хуках `@livekit/components-react`: список устройств и переключение —
|
||||
* целиком в `useMediaDeviceSelect` (сама подписана на
|
||||
* `RoomEvent.MediaDevicesChanged`), персист выбора — в `usePersistentUserChoices`
|
||||
* (localStorage, читается заново при следующем входе в комнату — см.
|
||||
* `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
|
||||
*
|
||||
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
|
||||
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
|
||||
* он создал бы отдельный, ни с чем не связанный `Room()` и переключал бы
|
||||
* устройство «в никуда».
|
||||
*/
|
||||
export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
|
||||
const toast = useToast()
|
||||
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
|
||||
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
|
||||
const camera = useMediaDeviceSelect({ kind: 'videoinput' })
|
||||
|
||||
async function handleMicChange(deviceId: string) {
|
||||
try {
|
||||
await mic.setActiveMediaDevice(deviceId)
|
||||
saveAudioInputDeviceId(deviceId)
|
||||
} catch {
|
||||
// activeDeviceId хука — источник истины, состояние селекта само не меняется.
|
||||
toast.show('Не удалось переключить микрофон — устройство занято или отключено', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCameraChange(deviceId: string) {
|
||||
try {
|
||||
await camera.setActiveMediaDevice(deviceId)
|
||||
saveVideoInputDeviceId(deviceId)
|
||||
} catch {
|
||||
toast.show('Не удалось переключить камеру — устройство занято или отключено', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="room-modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="device-settings-title"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="room-modal-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="room-modal-head">
|
||||
<h2 id="device-settings-title">Настройки устройств</h2>
|
||||
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
|
||||
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="room-field">
|
||||
<label htmlFor="device-settings-mic">Микрофон</label>
|
||||
<select
|
||||
id="device-settings-mic"
|
||||
value={mic.activeDeviceId}
|
||||
onChange={(e) => void handleMicChange(e.target.value)}
|
||||
>
|
||||
{/* Заглушка на случай, пока activeDeviceId не совпадает ни с одним
|
||||
устройством из списка (нет разрешения на медиа/список ещё не
|
||||
перечислен) — без неё controlled-select рассинхронизируется с
|
||||
DOM (ни одна из настоящих option не соответствует value). */}
|
||||
<option value="" disabled>
|
||||
Определяется…
|
||||
</option>
|
||||
{mic.devices.map((device, index) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{deviceLabel(device, index, 'Микрофон')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="room-field">
|
||||
<label htmlFor="device-settings-camera">Камера</label>
|
||||
<select
|
||||
id="device-settings-camera"
|
||||
value={camera.activeDeviceId}
|
||||
onChange={(e) => void handleCameraChange(e.target.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Определяется…
|
||||
</option>
|
||||
{camera.devices.map((device, index) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{deviceLabel(device, index, 'Камера')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
164
frontend/src/components/room/RoomParticipantTile.tsx
Normal file
164
frontend/src/components/room/RoomParticipantTile.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { ScreenShare } from 'lucide-react'
|
||||
import { Track } from 'livekit-client'
|
||||
import {
|
||||
AudioTrack,
|
||||
ConnectionQualityIndicator,
|
||||
FocusToggle,
|
||||
LockLockedIcon,
|
||||
ParticipantName,
|
||||
ParticipantTile,
|
||||
ScreenShareIcon,
|
||||
TrackMutedIndicator,
|
||||
VideoTrack,
|
||||
isTrackReference,
|
||||
useEnsureTrackRef,
|
||||
useFeatureContext,
|
||||
useIsEncrypted,
|
||||
useParticipantInfo,
|
||||
type ParticipantClickEvent,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-react'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
|
||||
/** Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata` на backend) — JSON `{"avatar_url": "..."}`; у гостей отсутствуют. */
|
||||
interface ParticipantMetadata {
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
/** Разбирает `participant.metadata` в URL аватара — `null`, если поля нет, метаданные пусты или невалидны (гость). */
|
||||
function parseAvatarUrl(metadata: string | undefined): string | null {
|
||||
if (!metadata) return null
|
||||
try {
|
||||
const parsed = JSON.parse(metadata) as ParticipantMetadata
|
||||
return typeof parsed.avatar_url === 'string' && parsed.avatar_url ? parsed.avatar_url : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Содержимое плитки — рендерится ВНУТРИ `ParticipantTile` (значит, внутри её
|
||||
* `TrackRefContext`/`ParticipantContext`), поэтому берёт трек и участника из
|
||||
* контекста теми же хуками, что использует сама библиотека в оригинальной
|
||||
* разметке (см. `node_modules/@livekit/components-react/src/components/participant/ParticipantTile.tsx`,
|
||||
* версия 2.9.23 — источник этой копии).
|
||||
*/
|
||||
function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
|
||||
const trackReference = useEnsureTrackRef()
|
||||
const isEncrypted = useIsEncrypted(trackReference.participant)
|
||||
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||
// useParticipantInfo — реактивные name/metadata участника (переподписка на
|
||||
// ParticipantMetadataChanged); в текущей версии metadata приходит статично
|
||||
// с токена при входе и в течение сеанса не меняется, но хук — штатный
|
||||
// способ читать её реактивно, если это когда-нибудь изменится.
|
||||
const { name, metadata } = useParticipantInfo({ participant: trackReference.participant })
|
||||
const avatarUrl = parseAvatarUrl(metadata)
|
||||
const displayName = name || trackReference.participant.identity
|
||||
// Чип «Вы демонстрируете экран» — только на СВОЕЙ демонстрации;
|
||||
// `onStopSharing` передаётся снаружи (`RoomStage.tsx`) только для фокус-
|
||||
// плитки, поэтому в карусели/гриде чип не появится даже при том же треке.
|
||||
const showSharingChip = Boolean(
|
||||
onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal,
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isTrackReference(trackReference) &&
|
||||
(trackReference.publication?.kind === 'video' ||
|
||||
trackReference.source === Track.Source.Camera ||
|
||||
trackReference.source === Track.Source.ScreenShare) ? (
|
||||
<VideoTrack trackRef={trackReference} manageSubscription={autoManageSubscription} />
|
||||
) : (
|
||||
isTrackReference(trackReference) && <AudioTrack trackRef={trackReference} />
|
||||
)}
|
||||
{/* Вместо штатной иконки-заглушки (ParticipantPlaceholder) — аватар
|
||||
участника: фото по avatar_url из метаданных, фолбэк — инициалы имени
|
||||
(см. `ui/Avatar`). Видимость (opacity) этого блока при выключенной
|
||||
камере управляется тем же CSS-правилом библиотеки
|
||||
(`[data-lk-video-muted='true'][data-lk-source='camera'] .lk-participant-placeholder`)
|
||||
— data-атрибуты на корневой div выставляет сам `ParticipantTile`
|
||||
независимо от children. */}
|
||||
<div className="lk-participant-placeholder">
|
||||
<Avatar name={displayName} avatarUrl={avatarUrl} className="room-tile-avatar" />
|
||||
</div>
|
||||
<div className="lk-participant-metadata">
|
||||
<div className="lk-participant-metadata-item">
|
||||
{trackReference.source === Track.Source.Camera ? (
|
||||
<>
|
||||
{isEncrypted && <LockLockedIcon style={{ marginRight: '0.25rem' }} />}
|
||||
<TrackMutedIndicator
|
||||
trackRef={{ participant: trackReference.participant, source: Track.Source.Microphone }}
|
||||
show="muted"
|
||||
/>
|
||||
<ParticipantName />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
|
||||
{/* `ParticipantName` рендерит `participant.name` (или identity),
|
||||
а `children` — необязательный суффикс ПОСЛЕ имени; оригинал
|
||||
дописывал сюда английское `'s screen` («Имя's screen»),
|
||||
здесь — русский суффикс («Имя — демонстрация экрана»). */}
|
||||
<ParticipantName>{' — демонстрация экрана'}</ParticipantName>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
</div>
|
||||
<FocusToggle trackRef={trackReference} />
|
||||
{showSharingChip && (
|
||||
<div className="stage-sharing-chip">
|
||||
<ScreenShare className="lucide" aria-hidden="true" />
|
||||
<span>Вы демонстрируете экран</span>
|
||||
<button type="button" onClick={onStopSharing}>
|
||||
Остановить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface RoomParticipantTileProps {
|
||||
trackRef?: TrackReferenceOrPlaceholder
|
||||
disableSpeakingIndicator?: boolean
|
||||
onParticipantClick?: (event: ParticipantClickEvent) => void
|
||||
/**
|
||||
* Остановить демонстрацию экрана — если передано, при рендере СВОЕЙ активной
|
||||
* демонстрации (Track.Source.ScreenShare + `participant.isLocal`) поверх
|
||||
* плитки показывается чип «Вы демонстрируете экран» с кнопкой «Остановить»
|
||||
* Передавать только для фокус-плитки — в карусели демонстрация
|
||||
* в этом приложении не появляется (см. `RoomStage.tsx`).
|
||||
*/
|
||||
onStopSharing?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Обёртка над штатным `ParticipantTile` (@livekit/components-react 2.9.23,
|
||||
* версия запинена в package.json — при апгрейде библиотеки сверить разметку
|
||||
* заново): та же структура (видео/аудио-трек, блок метаданных с именем и индикатором мьюта,
|
||||
* FocusToggle), но вместо стандартной SVG-пиктограммы при выключенной камере —
|
||||
* аватар участника. Работает и для локального участника (свои метаданные из
|
||||
* собственного токена), и для удалённых.
|
||||
*
|
||||
* Пин-логика оригинала (`handleSubscribe`/сброс пина при отписке от трека)
|
||||
* сознательно опущена — приложение пока нигде не создаёт `LayoutContext`
|
||||
* (пиннинг плиток не реализован), поэтому в оригинале эта ветка и так была
|
||||
* мёртвым кодом без провайдера контекста.
|
||||
*/
|
||||
export function RoomParticipantTile({
|
||||
trackRef,
|
||||
disableSpeakingIndicator,
|
||||
onParticipantClick,
|
||||
onStopSharing,
|
||||
}: RoomParticipantTileProps) {
|
||||
return (
|
||||
<ParticipantTile
|
||||
trackRef={trackRef}
|
||||
disableSpeakingIndicator={disableSpeakingIndicator}
|
||||
onParticipantClick={onParticipantClick}
|
||||
>
|
||||
<TileBody onStopSharing={onStopSharing} />
|
||||
</ParticipantTile>
|
||||
)
|
||||
}
|
||||
228
frontend/src/components/room/RoomStage.tsx
Normal file
228
frontend/src/components/room/RoomStage.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useState } from 'react'
|
||||
import { Track } from 'livekit-client'
|
||||
import {
|
||||
CarouselLayout,
|
||||
FocusLayoutContainer,
|
||||
GridLayout,
|
||||
RoomAudioRenderer,
|
||||
isTrackReference,
|
||||
useRoomContext,
|
||||
useSpeakingParticipants,
|
||||
useTracks,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-react'
|
||||
import { RoomParticipantTile } from '@/components/room/RoomParticipantTile'
|
||||
import { pickStageFocus } from '@/components/room/stageFocus'
|
||||
|
||||
/**
|
||||
* Стабильная (модульная, не пересоздаётся на каждый рендер) ссылка на
|
||||
* источники треков для `useTracks`. Важно: внутренний `useMemo` хука
|
||||
* `useTracks` держит СЫРОЙ параметр `sources` в списке зависимостей (не
|
||||
* только `trackReferences`/`participants`), поэтому инлайновый литерал
|
||||
* массива в вызове (`useTracks([{ source: ... }], ...)`) пересоздавался бы
|
||||
* заново при каждом рендере `RoomStage` и ломал мемоизацию: `tracks` был бы
|
||||
* НОВОЙ ссылкой на каждый рендер даже без реальных изменений участников.
|
||||
* Раньше это было безобидно (код ниже не сравнивал `tracks` по ссылке
|
||||
* между рендерами), но стало критично после того, как ниже появилось
|
||||
* состояние, которое обновляется именно по признаку «пришёл новый `tracks`»
|
||||
* (см. комментарий у `prevTracks`) — без этой константы получался бесконечный
|
||||
* цикл рендеров.
|
||||
*
|
||||
* Демонстрация экрана: к камере добавлен `Track.Source.ScreenShare`
|
||||
* без плейсхолдера (демонстрация либо есть, либо участника просто нет в этом
|
||||
* наборе — в отличие от камеры, «пустая» демонстрация не показывается вовсе).
|
||||
* Штатный паттерн смешанных источников — как в `VideoConference` из самой
|
||||
* `@livekit/components-react`.
|
||||
*/
|
||||
const STAGE_TRACK_SOURCES = [
|
||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||
]
|
||||
|
||||
/** Ключ трека для `pickStageFocus` — см. обоснование в `stageFocus.ts`. */
|
||||
function stageTrackKey(t: TrackReferenceOrPlaceholder): string {
|
||||
return `${t.participant.identity}:${t.source}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Основная сцена конференции: превью остальных участников + крупная плитка
|
||||
* активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
|
||||
* участниках, GridLayout при одном/двух).
|
||||
*
|
||||
* Раскладка — вертикальная колонка миниатюр слева от основной сцены (не
|
||||
* горизонтальная лента, см. design/mockups/room.html после правки: узкая
|
||||
* колонка сбоку, скролл по вертикали). Это штатное поведение самого
|
||||
* `@livekit/components-react` (2.9.23) — `FocusLayoutContainer` рендерит
|
||||
* `.lk-focus-layout` (`grid-template-columns: 1fr 5fr` — первая, узкая
|
||||
* колонка ровно под превью), а `CarouselLayout` без явного `orientation`
|
||||
* сам определяет направление по РАЗМЕРАМ СВОЕГО КОНТЕЙНЕРА (`height >= width`
|
||||
* → `vertical`) — важно НЕ передавать сюда className, перекрывающий базовый
|
||||
* `lk-carousel` (см. её исходник: `{...props}` после `className="lk-carousel"`
|
||||
* в JSX полностью заменяет, а не объединяет класс — раньше здесь было
|
||||
* `className="preview-row"`, из-за этого терялись все стили направления/
|
||||
* авторазмера тайлов, и лента визуально превращалась в горизонтальный ряд).
|
||||
* Адаптив уже встроен в @livekit/components-styles: на ≤600px
|
||||
* `.lk-focus-layout` сам переключается в колонку (спикер сверху, превью —
|
||||
* узкая горизонтальная полоса снизу, `.lk-carousel{order:1}`) — без
|
||||
* дополнительного JS с нашей стороны. Точечные доводки размеров/скроллбара —
|
||||
* styles/room.css (`.stage-tiles`/`.lk-carousel`).
|
||||
*
|
||||
* Демонстрация экрана: при наличии хотя бы одной активной
|
||||
* демонстрации `FocusLayoutContainer` форсируется БЕЗУСЛОВНО (независимо от
|
||||
* числа участников) — в фокусе демонстрация, в карусели ВСЕ camera-треки,
|
||||
* включая камеру самого демонстратора (решение архитектора). Без демонстрации
|
||||
* поведение не меняется — прежняя раскладка по спикеру/первому треку.
|
||||
*
|
||||
* Проп `variant="pip"` — для рендера
|
||||
* ВНУТРИ мини-плеера (Document PiP, портал в `RoomPage.tsx`). В этом режиме
|
||||
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида
|
||||
* — и фокус ЖИВО следует за активным спикером (см. `followSpeaker` у
|
||||
* `pickStageFocus`), а не удерживается, как в основном окне. Основной рендер
|
||||
* (`variant="full"`, дефолт) не меняется вовсе.
|
||||
*/
|
||||
export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
|
||||
const room = useRoomContext()
|
||||
const tracks = useTracks(STAGE_TRACK_SOURCES, {
|
||||
onlySubscribed: false,
|
||||
})
|
||||
// Только для PiP (см. followSpeaker ниже) — активные спикеры уже
|
||||
// отсортированы SDK по громкости (`Room.activeSpeakers`, обновляются по
|
||||
// `RoomEvent.ActiveSpeakersChanged`, событие шлётся лишь при РЕАЛЬНОЙ смене
|
||||
// состава/порядка говорящих — не дребезжит на каждый чих, в отличие от
|
||||
// сырого `participant.isSpeaking`). Хук вызывается безусловно (Rules of
|
||||
// Hooks) — для `variant="full"` его результат просто не используется.
|
||||
const speakingParticipants = useSpeakingParticipants()
|
||||
|
||||
const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera)
|
||||
const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare)
|
||||
const hasScreenShare = screenShareTracks.length > 0
|
||||
|
||||
// Кто «в фокусе» — храним по составному ключу (см. `stageTrackKey` и
|
||||
// обоснование в `stageFocus.ts`) в состоянии, а не пересчитываем заново из
|
||||
// порядка `tracks` на каждом рендере. Порядок этого массива не
|
||||
// гарантированно стабилен между кадрами (у @livekit/components-core он
|
||||
// может поменяться при переходе placeholder → реальный трек, при
|
||||
// подключении нового участника или при старте/остановке демонстрации), и
|
||||
// если брать `tracks[0]`/`.find()` напрямую при каждом рендере, «в фокусе»
|
||||
// и «в карусели» на паре соседних рендеров может оказаться то один, то
|
||||
// другой участник — тогда CarouselLayout получает на входе список, где
|
||||
// полностью сменился единственный элемент (был трек A, стал трек B), а её
|
||||
// собственный `useVisualStableUpdate` считает это не reflow'ом, а
|
||||
// поломанным состоянием и кидает (пойманную и залогированную, но всё
|
||||
// равно шумную) `Error: Element not part of the array`.
|
||||
//
|
||||
// Вся логика выбора «что дальше в фокусе» вынесена в чистую функцию
|
||||
// `pickStageFocus` (last-wins для нового шэра, удержание текущего фокуса,
|
||||
// фолбэк на активного спикера/первый трек) — здесь только вызов в
|
||||
// санкционированном React-паттерне «adjusting state during rendering» (не
|
||||
// useEffect — eslint-plugin-react-hooks не даёт ни читать ref во время
|
||||
// рендера, ни синхронно звать setState внутри эффекта), под охраной
|
||||
// сравнения с предыдущим `tracks` — сравниваем по ссылке, `useTracks` отдаёт
|
||||
// новый массив только когда реально что-то изменилось (см. комментарий у
|
||||
// `STAGE_TRACK_SOURCES` про стабильность ссылки).
|
||||
//
|
||||
// Для PiP (`variant="pip"`) пересчёт триггерится ЕЩЁ и сменой
|
||||
// `speakingParticipants` (тоже сравнение по ссылке — хук отдаёт новый
|
||||
// массив только при реальном изменении состава/порядка говорящих), и
|
||||
// передаётся `followSpeaker: true` — фокус живо переключается на нового
|
||||
// спикера, а не удерживает прежний (см. правило 2 в `pickStageFocus`). Для
|
||||
// основного окна (`variant="full"`) `speakingChanged` всегда `false` —
|
||||
// поведение байт-в-байт то же, что было до этой правки.
|
||||
const [prevTracks, setPrevTracks] = useState(tracks)
|
||||
const [prevSpeakingParticipants, setPrevSpeakingParticipants] = useState(speakingParticipants)
|
||||
const [focusKey, setFocusKey] = useState<string | null>(null)
|
||||
|
||||
const tracksChanged = tracks !== prevTracks
|
||||
const speakingChanged = variant === 'pip' && speakingParticipants !== prevSpeakingParticipants
|
||||
|
||||
if (tracksChanged || speakingChanged) {
|
||||
const prevKeys = prevTracks.map(stageTrackKey)
|
||||
if (tracksChanged) setPrevTracks(tracks)
|
||||
if (speakingChanged) setPrevSpeakingParticipants(speakingParticipants)
|
||||
// Источник «говорящих» — РАЗНЫЙ для основного окна и PiP, намеренно:
|
||||
// здесь строго тот же расчёт, что был в основном окне ДО этой правки
|
||||
// (`participant.isSpeaking`, без сортировки — фолбэк только на первый
|
||||
// рендер, дребезг неважен, см. JSDoc правила 3/4 в stageFocus.ts), а для
|
||||
// PiP — упорядоченный по громкости `speakingParticipants` (нужен именно
|
||||
// порядок, чтобы взять самого громкого, и именно throttled-источник SDK,
|
||||
// чтобы followSpeaker не дёргался на каждый чих).
|
||||
const speakingCameraKeys =
|
||||
variant === 'pip'
|
||||
? speakingParticipants
|
||||
.map((p) => cameraTracks.find((t) => t.participant.identity === p.identity))
|
||||
.filter((t): t is TrackReferenceOrPlaceholder => Boolean(t))
|
||||
.map(stageTrackKey)
|
||||
: cameraTracks.filter((t) => t.participant.isSpeaking).map(stageTrackKey)
|
||||
const result = pickStageFocus({
|
||||
cameraKeys: cameraTracks.map(stageTrackKey),
|
||||
screenShareKeys: screenShareTracks.map(stageTrackKey),
|
||||
speakingCameraKeys,
|
||||
prevKeys,
|
||||
prevFocusKey: focusKey,
|
||||
followSpeaker: variant === 'pip',
|
||||
// Только для PiP — в основном окне фолбэк на «первый трек» не менялся
|
||||
// (см. JSDoc про speakingChanged выше: поведение full-варианта не трогаем).
|
||||
localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null,
|
||||
})
|
||||
if (result.focusKey !== focusKey) {
|
||||
setFocusKey(result.focusKey)
|
||||
}
|
||||
}
|
||||
|
||||
const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0]
|
||||
const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null
|
||||
// При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И
|
||||
// ВСЕ демонстрации, проигравшие фокус (второй демонстратор при last-wins —
|
||||
// см. `pickStageFocus`): её трек продолжает публиковаться, и, по решению
|
||||
// архитектора, должен остаться видимой обычной плиткой в карусели, а не
|
||||
// молча пропадать из UI. Сравнение — по тому же ключу `identity:source`,
|
||||
// что и у `pickStageFocus` (устойчивее прямого сравнения ссылок между
|
||||
// рендерами). Когда демонстратор один — его screenshare как раз и есть
|
||||
// focusTrack, фильтр исключает его из карусели (дубля нет).
|
||||
const carouselTracks = hasScreenShare
|
||||
? [...cameraTracks, ...screenShareTracks.filter((t) => stageTrackKey(t) !== focusTrackKey)]
|
||||
: tracks.filter((t) => t !== focusTrack)
|
||||
|
||||
/**
|
||||
* Остановить СВОЮ демонстрацию — передаётся в фокус-плитку, чип «Вы
|
||||
* демонстрируете экран» и его видимость решает сама `RoomParticipantTile`
|
||||
* (показывает только когда трек фокуса — своя демонстрация экрана).
|
||||
*/
|
||||
function handleStopSharing() {
|
||||
void room.localParticipant.setScreenShareEnabled(false)
|
||||
}
|
||||
|
||||
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
|
||||
// грида, одна плитка на весь контейнер (см. `.room-single-tile`,
|
||||
// `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus`
|
||||
// (с `followSpeaker: true` для этого варианта) — переиспользуем как есть.
|
||||
if (variant === 'pip') {
|
||||
return (
|
||||
<section className="stage room-single-tile">
|
||||
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
|
||||
<RoomAudioRenderer />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="stage">
|
||||
{!hasScreenShare && (!focusTrack || carouselTracks.length === 0) ? (
|
||||
<GridLayout tracks={tracks} className="stage-tiles">
|
||||
<RoomParticipantTile />
|
||||
</GridLayout>
|
||||
) : (
|
||||
<FocusLayoutContainer className="stage-tiles">
|
||||
<CarouselLayout tracks={carouselTracks}>
|
||||
<RoomParticipantTile />
|
||||
</CarouselLayout>
|
||||
{/* FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile
|
||||
(см. её исходник), поэтому вместо неё используем свою обёртку
|
||||
напрямую с тем же trackRef (аватар в фокус-плитке). */}
|
||||
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
|
||||
</FocusLayoutContainer>
|
||||
)}
|
||||
<RoomAudioRenderer />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
215
frontend/src/components/room/RoomToolbar.tsx
Normal file
215
frontend/src/components/room/RoomToolbar.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
LogOut,
|
||||
Maximize,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
MicOff,
|
||||
Minimize,
|
||||
PictureInPicture2,
|
||||
ScreenShare,
|
||||
ScreenShareOff,
|
||||
Settings,
|
||||
Video,
|
||||
VideoOff,
|
||||
} from 'lucide-react'
|
||||
import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
|
||||
import { DisconnectButton, useTrackToggle } from '@livekit/components-react'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
|
||||
/**
|
||||
* Опции захвата демонстрации экрана: `audio: true` — звук
|
||||
* вкладки/экрана там, где браузер его отдаёт (Chrome/Edge — вкладка почти
|
||||
* всегда, целый экран — только Windows); `selfBrowserSurface: 'exclude'`
|
||||
* — не предлагать в списке
|
||||
* источников собственную вкладку (зеркальный туннель самой конференции);
|
||||
* `surfaceSwitching: 'include'` — разрешить переключать источник прямо во
|
||||
* время демонстрации, не останавливая её; `systemAudio: 'include'` — не
|
||||
* запрещать захват системного звука при выборе «весь экран». Вынесено в
|
||||
* модульную константу — `useTrackToggle` держит `JSON.stringify(captureOptions)`
|
||||
* в зависимостях внутреннего `useMemo`, инлайновый литерал был бы безвреден,
|
||||
* но константа явнее фиксирует неизменность опций.
|
||||
*/
|
||||
const SCREEN_SHARE_CAPTURE_OPTIONS: ScreenShareCaptureOptions = {
|
||||
audio: true,
|
||||
selfBrowserSurface: 'exclude',
|
||||
surfaceSwitching: 'include',
|
||||
systemAudio: 'include',
|
||||
}
|
||||
|
||||
interface RoomToolbarProps {
|
||||
/** Показывать ли кнопку чата — `JoinOut.chat_enabled` И чат не помечен недоступным (close-код 4404). */
|
||||
chatVisible: boolean
|
||||
chatOpen: boolean
|
||||
/** Число непрочитанных сообщений, накопленных со времени последнего открытия панели. */
|
||||
chatUnreadCount: number
|
||||
onToggleChat: () => void
|
||||
/** Открыть диалог настроек устройств. */
|
||||
onToggleSettings: () => void
|
||||
/** `document.fullscreenEnabled` — false скрывает кнопку. */
|
||||
fullscreenSupported: boolean
|
||||
fullscreenActive: boolean
|
||||
onToggleFullscreen: () => void
|
||||
/** Доступен ли мини-плеер хоть в каком-то виде (Document PiP либо video-фолбэк) — false в Firefox скрывает кнопку. */
|
||||
pipSupported: boolean
|
||||
pipActive: boolean
|
||||
onTogglePiP: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/
|
||||
* настройки устройств/полноэкранный режим/мини-плеер/чат/выход — собственные
|
||||
* кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и панели чата,
|
||||
* стилизованные по design/mockups/room.html.
|
||||
*/
|
||||
export function RoomToolbar({
|
||||
chatVisible,
|
||||
chatOpen,
|
||||
chatUnreadCount,
|
||||
onToggleChat,
|
||||
onToggleSettings,
|
||||
fullscreenSupported,
|
||||
fullscreenActive,
|
||||
onToggleFullscreen,
|
||||
pipSupported,
|
||||
pipActive,
|
||||
onTogglePiP,
|
||||
}: RoomToolbarProps) {
|
||||
const toast = useToast()
|
||||
const mic = useTrackToggle({ source: Track.Source.Microphone })
|
||||
const camera = useTrackToggle({ source: Track.Source.Camera })
|
||||
const screenShare = useTrackToggle({
|
||||
source: Track.Source.ScreenShare,
|
||||
captureOptions: SCREEN_SHARE_CAPTURE_OPTIONS,
|
||||
// Отмена браузерного диалога выбора экрана/окна (пользователь нажал
|
||||
// «Отмена») приходит сюда как NotAllowedError — молча игнорируем, это не
|
||||
// ошибка приложения. Прочие сбои (например, `NotReadableError` при
|
||||
// занятом источнике) показываем тостом.
|
||||
onDeviceError: (error) => {
|
||||
if (error.name === 'NotAllowedError') return
|
||||
toast.show('Не удалось начать демонстрацию экрана', 'error')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<footer className="room-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
{...mic.buttonProps}
|
||||
className={`tb-btn${mic.enabled ? '' : ' is-off'}`}
|
||||
aria-label={mic.enabled ? 'Выключить микрофон' : 'Включить микрофон'}
|
||||
>
|
||||
<span className="icon-shell">
|
||||
{mic.enabled ? (
|
||||
<Mic className="lucide" aria-hidden="true" />
|
||||
) : (
|
||||
<MicOff className="lucide" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="label">Микрофон</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
{...camera.buttonProps}
|
||||
className={`tb-btn${camera.enabled ? '' : ' is-off'}`}
|
||||
aria-label={camera.enabled ? 'Выключить камеру' : 'Включить камеру'}
|
||||
>
|
||||
<span className="icon-shell">
|
||||
{camera.enabled ? (
|
||||
<Video className="lucide" aria-hidden="true" />
|
||||
) : (
|
||||
<VideoOff className="lucide" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="label">Камера</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
{...screenShare.buttonProps}
|
||||
className={`tb-btn${screenShare.enabled ? ' is-sharing' : ''}`}
|
||||
aria-label={screenShare.enabled ? 'Остановить демонстрацию экрана' : 'Демонстрировать экран'}
|
||||
>
|
||||
<span className="icon-shell">
|
||||
{screenShare.enabled ? (
|
||||
<ScreenShareOff className="lucide" aria-hidden="true" />
|
||||
) : (
|
||||
<ScreenShare className="lucide" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="label">Демонстрация</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="tb-btn"
|
||||
aria-label="Настройки устройств"
|
||||
onClick={onToggleSettings}
|
||||
>
|
||||
<span className="icon-shell">
|
||||
<Settings className="lucide" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="label">Устройства</span>
|
||||
</button>
|
||||
|
||||
{fullscreenSupported && (
|
||||
<button
|
||||
type="button"
|
||||
className={`tb-btn${fullscreenActive ? ' is-panel-open' : ''}`}
|
||||
aria-label={fullscreenActive ? 'Выйти из полноэкранного режима' : 'Развернуть на весь экран'}
|
||||
aria-pressed={fullscreenActive}
|
||||
onClick={onToggleFullscreen}
|
||||
>
|
||||
<span className="icon-shell">
|
||||
{fullscreenActive ? (
|
||||
<Minimize className="lucide" aria-hidden="true" />
|
||||
) : (
|
||||
<Maximize className="lucide" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="label">Экран</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pipSupported && (
|
||||
<button
|
||||
type="button"
|
||||
className={`tb-btn${pipActive ? ' is-panel-open' : ''}`}
|
||||
aria-label={pipActive ? 'Свернуть мини-окно' : 'Открыть мини-окно'}
|
||||
aria-pressed={pipActive}
|
||||
onClick={onTogglePiP}
|
||||
>
|
||||
<span className="icon-shell">
|
||||
<PictureInPicture2 className="lucide" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="label">Мини-окно</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{chatVisible && (
|
||||
<button
|
||||
type="button"
|
||||
className={`tb-btn${chatOpen ? ' is-panel-open' : ''}`}
|
||||
aria-label={chatOpen ? 'Свернуть чат' : 'Открыть чат'}
|
||||
aria-pressed={chatOpen}
|
||||
onClick={onToggleChat}
|
||||
>
|
||||
<span className="icon-shell">
|
||||
<MessageSquare className="lucide" aria-hidden="true" />
|
||||
{chatUnreadCount > 0 && (
|
||||
<span className="badge-count">{chatUnreadCount > 9 ? '9+' : chatUnreadCount}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="label">Чат</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции">
|
||||
<span className="icon-shell">
|
||||
<LogOut className="lucide" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="label">Выйти</span>
|
||||
</DisconnectButton>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
62
frontend/src/components/room/RoomTopbar.tsx
Normal file
62
frontend/src/components/room/RoomTopbar.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Check, Copy, Users } from 'lucide-react'
|
||||
import { useParticipants } from '@livekit/components-react'
|
||||
import { useState } from 'react'
|
||||
import { pluralizeParticipants } from '@/lib/pluralize'
|
||||
|
||||
interface RoomTopbarProps {
|
||||
roomName: string
|
||||
/** Slug/номер конференции из адреса — для инвайт-чипа (копирование ссылки). */
|
||||
slug?: string
|
||||
number?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Верхняя панель комнаты: название, число участников и инвайт-чип
|
||||
* (номер + копирование ссылки-приглашения `/j/:slug`).
|
||||
*
|
||||
* Примечание: в design/mockups/room.html этого чипа нет — он добавлен,
|
||||
* чтобы ссылка и номер конференции были видны и копировались, в стиле
|
||||
* тёмных токенов темы `room` (см. `--color-room-tile*`), без новых
|
||||
* цветов и форм.
|
||||
*/
|
||||
export function RoomTopbar({ roomName, slug, number }: RoomTopbarProps) {
|
||||
const participants = useParticipants()
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function handleCopy() {
|
||||
if (!slug) return
|
||||
const link = `${window.location.origin}/j/${slug}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// Буфер обмена недоступен (нет разрешения/не https) — молча игнорируем,
|
||||
// это некритичный вспомогательный элемент интерфейса.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="room-topbar">
|
||||
<div className="room-title-block">
|
||||
<h1>{roomName}</h1>
|
||||
<p>
|
||||
<span className="rec-dot" aria-hidden="true" />
|
||||
Конференция активна · <Users className="lucide" style={{ width: 14, height: 14 }} aria-hidden="true" />{' '}
|
||||
{participants.length} {pluralizeParticipants(participants.length)}
|
||||
</p>
|
||||
</div>
|
||||
{slug && (
|
||||
<button type="button" className="room-invite-chip" onClick={handleCopy}>
|
||||
<span>{number ? `№ ${number}` : slug}</span>
|
||||
{copied ? (
|
||||
<Check style={{ width: 14, height: 14 }} aria-hidden="true" />
|
||||
) : (
|
||||
<Copy style={{ width: 14, height: 14 }} aria-hidden="true" />
|
||||
)}
|
||||
{copied ? 'Скопировано' : 'Пригласить'}
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
133
frontend/src/components/room/stageFocus.ts
Normal file
133
frontend/src/components/room/stageFocus.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Чистая функция выбора «сцены в фокусе» (демонстрация экрана).
|
||||
*
|
||||
* Никаких зависимостей от React/DOM/LiveKit SDK — на вход только примитивы,
|
||||
* на выход тоже примитивы; можно покрыть unit-тестом при появлении раннера
|
||||
* (vitest в проект сознательно не вводим, тестируем вручную).
|
||||
*
|
||||
* Ключ трека — НЕ sid публикации и НЕ голая identity участника, а составной
|
||||
* `${identity}:${source}` (см. `RoomStage.tsx`, функция `stageTrackKey`):
|
||||
* - голой identity недостаточно — у демонстратора одновременно живут camera-
|
||||
* и screenshare-трек, и по одной identity нельзя понять, какой из них в
|
||||
* фокусе;
|
||||
* - чистый sid публикации нестабилен для камеры: каждое вкл/выкл камеры
|
||||
* (`setCameraEnabled`) — это unpublish/publish с НОВЫМ sid, а при
|
||||
* выключенной камере используется placeholder без sid вовсе (см. исходный
|
||||
* комментарий в RoomStage.tsx про гонку placeholder → реальный трек). Если
|
||||
* бы ключом был sid, сфокусированный участник терял бы фокус на каждом
|
||||
* цикле мьюта камеры.
|
||||
* `identity:source` даёт и устойчивость (не меняется при мьюте камеры), и
|
||||
* однозначность (camera и screen_share одного участника — разные ключи).
|
||||
*/
|
||||
|
||||
/** Вид источника трека на сцене. */
|
||||
export type StageFocusKind = 'camera' | 'screen_share'
|
||||
|
||||
export interface PickStageFocusInput {
|
||||
/** Ключи текущих камера-треков (один на участника — трек либо его плейсхолдер). */
|
||||
cameraKeys: readonly string[]
|
||||
/** Ключи текущих screenshare-треков (один на активную демонстрацию). */
|
||||
screenShareKeys: readonly string[]
|
||||
/**
|
||||
* Подмножество `cameraKeys` — говорящие сейчас участники, УПОРЯДОЧЕННОЕ по
|
||||
* громкости (первый — самый громкий; см. `Room.activeSpeakers`, который
|
||||
* сортирует именно так) — для фолбэка на активного спикера и, при
|
||||
* `followSpeaker`, для живого переключения фокуса.
|
||||
*/
|
||||
speakingCameraKeys: readonly string[]
|
||||
/** Объединённый набор ключей (camera+screenshare) с ПРЕДЫДУЩЕГО рендера — определяет, какие screenshare-ключи «новые». */
|
||||
prevKeys: readonly string[]
|
||||
/** Ключ, что был в фокусе на предыдущем рендере; `null` — фокус ещё не выбирался. */
|
||||
prevFocusKey: string | null
|
||||
/**
|
||||
* Режим мини-плеера (PiP): фокус должен ЖИВО следовать за
|
||||
* активным спикером (переключаться сразу, а не удерживать текущий), в
|
||||
* отличие от основного окна сцены — там держим фокус, даже если заговорил
|
||||
* кто-то другой (см. правило 2 ниже и обоснование в `RoomStage.tsx` про
|
||||
* дребезг `isSpeaking` у фейковых медиапотоков). По умолчанию `false` —
|
||||
* поведение основного окна не меняется.
|
||||
*/
|
||||
followSpeaker?: boolean
|
||||
/**
|
||||
* Ключ локального участника (та же схема `identity:source`) — предпоследний
|
||||
* фолбэк, ПЕРЕД чисто первым элементом набора: если фокуса ещё не было и
|
||||
* никто не говорит, лучше показать «себя», чем произвольного первого
|
||||
* участника (актуально для свежего открытия PiP). Не указан — фолбэк не
|
||||
* меняется (последний, по порядку `cameraKeys`).
|
||||
*/
|
||||
localKey?: string | null
|
||||
}
|
||||
|
||||
export interface PickStageFocusResult {
|
||||
/** Ключ трека в фокусе; `null` — треков нет вовсе. */
|
||||
focusKey: string | null
|
||||
kind: StageFocusKind | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбирает, какой трек показать крупно (в `FocusLayoutContainer`).
|
||||
*
|
||||
* Правила (PiP показывает только активное окно):
|
||||
* 1. Last-wins: если с прошлого рендера появился НОВЫЙ screenshare-ключ —
|
||||
* фокус безусловно переходит на него (последний из новых, если появилось
|
||||
* сразу несколько), даже если до этого в фокусе была камера или другая
|
||||
* демонстрация. Так же ведут себя типовые UI конференций (Google Meet).
|
||||
* 2. `followSpeaker` (только PiP): если сейчас есть говорящий — фокус СРАЗУ
|
||||
* переходит на него, даже если текущий фокус ещё жив. В основном окне
|
||||
* (`followSpeaker: false`) этот шаг пропускается — см. правило 3.
|
||||
* 3. Иначе, если текущий фокус жив (остался среди camera/screenshare-ключей) —
|
||||
* держим его: НЕ дёргаем фокус на каждый ре-рендер (изменение состава
|
||||
* участников, дребезг isSpeaking и т.п.). Это и есть «стабильный фолбэк»
|
||||
* для PiP, когда никто не говорит — держим предыдущего активного.
|
||||
* 4. Иначе (фокуса не было или он пропал) — приоритет активной демонстрации
|
||||
* над камерой; среди камер — активный спикер, иначе `localKey` (если
|
||||
* указан и жив), иначе первая по порядку.
|
||||
*/
|
||||
export function pickStageFocus({
|
||||
cameraKeys,
|
||||
screenShareKeys,
|
||||
speakingCameraKeys,
|
||||
prevKeys,
|
||||
prevFocusKey,
|
||||
followSpeaker = false,
|
||||
localKey = null,
|
||||
}: PickStageFocusInput): PickStageFocusResult {
|
||||
if (cameraKeys.length === 0 && screenShareKeys.length === 0) {
|
||||
return { focusKey: null, kind: null }
|
||||
}
|
||||
|
||||
const prevKeySet = new Set(prevKeys)
|
||||
const newScreenShareKeys = screenShareKeys.filter((key) => !prevKeySet.has(key))
|
||||
if (newScreenShareKeys.length > 0) {
|
||||
return { focusKey: newScreenShareKeys[newScreenShareKeys.length - 1], kind: 'screen_share' }
|
||||
}
|
||||
|
||||
if (followSpeaker) {
|
||||
const liveSpeaker = speakingCameraKeys.find((key) => cameraKeys.includes(key))
|
||||
if (liveSpeaker) {
|
||||
return { focusKey: liveSpeaker, kind: 'camera' }
|
||||
}
|
||||
}
|
||||
|
||||
if (prevFocusKey && screenShareKeys.includes(prevFocusKey)) {
|
||||
return { focusKey: prevFocusKey, kind: 'screen_share' }
|
||||
}
|
||||
if (prevFocusKey && cameraKeys.includes(prevFocusKey)) {
|
||||
return { focusKey: prevFocusKey, kind: 'camera' }
|
||||
}
|
||||
|
||||
if (screenShareKeys.length > 0) {
|
||||
return { focusKey: screenShareKeys[screenShareKeys.length - 1], kind: 'screen_share' }
|
||||
}
|
||||
|
||||
const speaking = speakingCameraKeys.find((key) => cameraKeys.includes(key))
|
||||
if (speaking) {
|
||||
return { focusKey: speaking, kind: 'camera' }
|
||||
}
|
||||
|
||||
if (localKey && cameraKeys.includes(localKey)) {
|
||||
return { focusKey: localKey, kind: 'camera' }
|
||||
}
|
||||
|
||||
return { focusKey: cameraKeys[0] ?? null, kind: cameraKeys[0] ? 'camera' : null }
|
||||
}
|
||||
31
frontend/src/components/ui/Avatar.tsx
Normal file
31
frontend/src/components/ui/Avatar.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Аватар пользователя — переиспользуемый `.avatar` (DESIGN_SYSTEM.md §1.5):
|
||||
* фото, если загружено (`avatarUrl`), иначе заглушка с инициалами имени.
|
||||
* Используется в топбаре, таблице «Пользователи» админки, пикере участников
|
||||
* конференции и ховер-карточке.
|
||||
*/
|
||||
interface AvatarProps {
|
||||
name: string
|
||||
avatarUrl?: string | null
|
||||
/** Размер в пикселях — по умолчанию 28px, как базовый `.avatar` в shell.css. */
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 0) return '??'
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase())
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function Avatar({ name, avatarUrl, size, className }: AvatarProps) {
|
||||
const style = size ? { width: size, height: size } : undefined
|
||||
return (
|
||||
<span className={`avatar${className ? ` ${className}` : ''}`} style={style} aria-hidden="true">
|
||||
{avatarUrl ? <img src={avatarUrl} alt="" /> : initialsOf(name)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
103
frontend/src/components/ui/ConferenceHoverCard.tsx
Normal file
103
frontend/src/components/ui/ConferenceHoverCard.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useMemo, type CSSProperties } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Repeat, Users as UsersIcon } from 'lucide-react'
|
||||
import { getConference } from '@/api/conferences'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
|
||||
interface ConferenceHoverCardProps {
|
||||
/** `null` — карточка скрыта. */
|
||||
conferenceId: string | null
|
||||
/** Элемент, от которого позиционируется поповер (чип календаря, карточка «Моих конференций»). */
|
||||
anchorEl: HTMLElement | null
|
||||
}
|
||||
|
||||
const CARD_WIDTH = 300
|
||||
|
||||
/** Позиция карточки — чистая функция от уже смонтированного `anchorEl` (без DOM-эффекта: `getBoundingClientRect` доступен и во время рендера). */
|
||||
function computeStyle(anchorEl: HTMLElement): CSSProperties {
|
||||
const rect = anchorEl.getBoundingClientRect()
|
||||
const left = Math.min(rect.left, window.innerWidth - CARD_WIDTH - 12)
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const openUpwards = spaceBelow < 220
|
||||
return {
|
||||
position: 'fixed',
|
||||
left: Math.max(12, left),
|
||||
top: openUpwards ? undefined : rect.bottom + 8,
|
||||
bottom: openUpwards ? window.innerHeight - rect.top + 8 : undefined,
|
||||
width: CARD_WIDTH,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ховер-карточка конференции — новый элемент без
|
||||
* готового макета: собрана из утверждённых паттернов (карточка `--shadow-lg`
|
||||
* как у поповеров/модалок §6, бейдж закреплённости — иконка `Repeat` как в
|
||||
* §4.6). Данные — ленивый `GET /conferences/{id}` (доступен только owner|admin
|
||||
* — для конференций без прав на просмотр карточка тихо не показывается,
|
||||
* см. `isError`), закэшированы react-query по `conferenceId`.
|
||||
*
|
||||
* Рендерится через портал в `document.body` с `position: fixed`, чтобы не
|
||||
* обрезаться `overflow` ячеек календаря/грида карточек. `pointer-events: none`
|
||||
* на самой карточке — не должна перехватывать клики по чипу/карточке под ней.
|
||||
*/
|
||||
export function ConferenceHoverCard({ conferenceId, anchorEl }: ConferenceHoverCardProps) {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['conferences', 'detail', conferenceId],
|
||||
queryFn: () => getConference(conferenceId!),
|
||||
enabled: !!conferenceId,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
// getBoundingClientRect читает уже отрисованный DOM (anchorEl смонтирован
|
||||
// родителем раньше) — не побочный эффект рендера, а чтение геометрии
|
||||
// существующего узла, поэтому вычисляем прямо в теле рендера (useMemo).
|
||||
const style = useMemo(() => (anchorEl ? computeStyle(anchorEl) : null), [anchorEl])
|
||||
|
||||
if (!conferenceId || !anchorEl || !style || isError) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="conf-hover-card" style={style} role="tooltip" aria-live="polite">
|
||||
{isLoading || !data ? (
|
||||
<p className="conf-hover-card-hint">Загрузка…</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="conf-hover-card-title">{data.title ?? 'Конференция без названия'}</p>
|
||||
<p className="conf-hover-card-row">
|
||||
<span className="conf-hover-card-label">Организатор</span>
|
||||
{data.organizer_name ?? '—'}
|
||||
</p>
|
||||
<p className="conf-hover-card-row">
|
||||
{data.is_pinned ? (
|
||||
<>
|
||||
<Repeat style={{ width: 13, height: 13 }} aria-hidden="true" />
|
||||
Закреплённая (постоянная)
|
||||
</>
|
||||
) : (
|
||||
'Разовая'
|
||||
)}
|
||||
</p>
|
||||
{data.participants && data.participants.length > 0 && (
|
||||
<div className="conf-hover-card-participants">
|
||||
<span className="conf-hover-card-label">
|
||||
<UsersIcon style={{ width: 13, height: 13 }} aria-hidden="true" />
|
||||
Участники ({data.participants.length})
|
||||
</span>
|
||||
<ul>
|
||||
{data.participants.map((p) => (
|
||||
<li key={p.user_id ?? p.email}>
|
||||
<Avatar name={p.name ?? p.email} avatarUrl={p.avatar_url} size={18} />
|
||||
{p.name ?? p.email}
|
||||
{p.is_organizer && <span className="conf-hover-card-tag">организатор</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
55
frontend/src/components/ui/CopyPill.tsx
Normal file
55
frontend/src/components/ui/CopyPill.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
|
||||
interface CopyPillProps {
|
||||
label: string
|
||||
/** Отображаемое (может быть обрезано `text-overflow: ellipsis`) значение. */
|
||||
value: string
|
||||
/** Что именно копируется в буфер обмена (может отличаться от отображаемого значения). */
|
||||
copyText: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Пилюля «ссылка/номер» с копированием (DESIGN_SYSTEM.md §4.15, «Мои
|
||||
* конференции»). Статус «скопировано» передаётся сменой формы иконки
|
||||
* (Copy → Check) и текстовым тултипом — не только цветом.
|
||||
*/
|
||||
export function CopyPill({ label, value, copyText }: CopyPillProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(copyText)
|
||||
} catch {
|
||||
// Буфер обмена недоступен (нет разрешения/небезопасный контекст) — молча игнорируем.
|
||||
}
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pill-group">
|
||||
<span className="pill-label">{label}</span>
|
||||
<div className="code-pill">
|
||||
<span className="pill-value">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="copy-btn"
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? 'Скопировано' : `Скопировать: ${label.toLowerCase()}`}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="icon-check" style={{ width: 14, height: 14 }} aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="icon-copy" style={{ width: 14, height: 14 }} aria-hidden="true" />
|
||||
)}
|
||||
{copied && (
|
||||
<span className="copy-toast" aria-hidden="true">
|
||||
Скопировано
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
frontend/src/components/ui/LogoMark.tsx
Normal file
67
frontend/src/components/ui/LogoMark.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Логотип VidConf — знак «объектив» (design/DESIGN_SYSTEM.md §1.4, design/logo.svg).
|
||||
* Монохромный, наследует цвет текста (currentColor) — обычно `--color-ink-700`.
|
||||
*
|
||||
* Два варианта:
|
||||
* - `LogoMark` — упрощённая версия для мелких размеров (топбар рядом со
|
||||
* словом «VidConf», 22×22px), 1:1 повторяет `.brand-mark .logo-mark` из
|
||||
* всех макетов оболочки (lobby.html, calendar.html, admin.html, join.html).
|
||||
* - `LogoMedallion` — полная версия с 4 концентрическими кольцами (design/logo.svg),
|
||||
* используется в медальоне-центре мозаики лобби (`.hub-logo`, 56×56px внутри
|
||||
* круга 156×156px, см. design/mockups/lobby.html).
|
||||
*/
|
||||
|
||||
/** Упрощённый знак объектива для топбара (viewBox 32×32). */
|
||||
export function LogoMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? 'logo-mark'} viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||
<polygon points="21,16 18.5,20.3 13.5,20.3 11,16 13.5,11.7 18.5,11.7" fill="currentColor" />
|
||||
<circle cx="16" cy="16" r="13" stroke="currentColor" strokeWidth="2.6" strokeDasharray="12 6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Полный знак объектива (4 кольца) для медальона в центре мозаики лобби. */
|
||||
export function LogoMedallion({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? 'logo-mark'} viewBox="0 0 64 64" fill="none" aria-hidden="true">
|
||||
<polygon points="41,32 36.5,39.8 27.5,39.8 23,32 27.5,24.2 36.5,24.2" fill="currentColor" />
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeDasharray="16 7 28 9 12 16"
|
||||
strokeDashoffset="5"
|
||||
/>
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="22 10 8 14 34 12 6 10"
|
||||
strokeDashoffset="40"
|
||||
/>
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="25"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3.5"
|
||||
strokeDasharray="40 14 12 10 26 16 20 8"
|
||||
strokeDashoffset="80"
|
||||
/>
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="29"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeDasharray="10 6 60 20 30 10 24 8"
|
||||
strokeDashoffset="120"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
233
frontend/src/components/ui/Select.tsx
Normal file
233
frontend/src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { AlertCircle, Check, ChevronDown } from 'lucide-react'
|
||||
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: SelectOption[]
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
/** Показать ошибку (2px `--color-danger`, иконка `AlertCircle`) — текст ошибки рисует вызывающий код под полем. */
|
||||
error?: boolean
|
||||
id?: string
|
||||
'aria-label'?: string
|
||||
'aria-labelledby'?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Кастомный селект (DESIGN_SYSTEM.md §4.16) — заменяет нативный `<select>`.
|
||||
* Паттерн listbox-combobox: триггер — геометрия инпута (§4.4), дроплист —
|
||||
* паттерн `.menu` (design/mockups/lobby.html), пункт — `.menu a`. Полностью
|
||||
* на var()-токенах — обе темы работают без отдельных правил (см. §4.16
|
||||
* «Обе темы»).
|
||||
*
|
||||
* Клавиатура: Enter/Space/стрелки открывают список (фокус остаётся на
|
||||
* триггере), стрелки двигают клавиатурную подсветку (не по кругу),
|
||||
* Home/End — к границам, ввод буквы — type-ahead, Enter/Space на подсвеченном
|
||||
* пункте — выбор и закрытие, Esc/клик вне/Tab — закрытие без изменений.
|
||||
*/
|
||||
export function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Выберите…',
|
||||
disabled = false,
|
||||
error = false,
|
||||
id,
|
||||
...aria
|
||||
}: SelectProps) {
|
||||
const generatedId = useId()
|
||||
const baseId = id ?? generatedId
|
||||
const listboxId = `${baseId}-listbox`
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const optionRefs = useRef<Map<string, HTMLLIElement>>(new Map())
|
||||
const typeaheadRef = useRef<{ text: string; timer: ReturnType<typeof window.setTimeout> | undefined }>({
|
||||
text: '',
|
||||
timer: undefined,
|
||||
})
|
||||
|
||||
const enabledOptions = useMemo(() => options.filter((o) => !o.disabled), [options])
|
||||
const selectedOption = options.find((o) => o.value === value) ?? null
|
||||
|
||||
// `null` — единственный сентинел «нет активного пункта»; пустая строка
|
||||
// ('' — например, «По умолчанию» в SUMMARY_RECIPIENTS_OPTIONS) — легитимное
|
||||
// значение и НЕ должна схлопываться в `null` через `||`-фоллбэки (был баг:
|
||||
// ArrowDown ставил activeValue='', а `if (activeValue)`/`activeValue ? …`
|
||||
// ниже трактовали это как «ничего не подсвечено», из-за чего Enter не
|
||||
// выбирал пункт и aria-activedescendant не выставлялся). Поэтому везде ниже
|
||||
// сравнение только с `null` (`!== null`/`=== null`), никогда `||`/truthiness.
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeValue, setActiveValue] = useState<string | null>(
|
||||
enabledOptions.some((o) => o.value === value) ? value : null,
|
||||
)
|
||||
|
||||
function closePanel() {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
if (disabled) return
|
||||
const current = enabledOptions.find((o) => o.value === value)
|
||||
setActiveValue(current ? current.value : (enabledOptions[0]?.value ?? null))
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
// Закрытие по клику вне триггера/панели — тот же приём, что бургер-меню ShellTopbar.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) closePanel()
|
||||
}
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && activeValue !== null) {
|
||||
optionRefs.current.get(activeValue)?.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}, [open, activeValue])
|
||||
|
||||
function moveActive(direction: 1 | -1) {
|
||||
if (enabledOptions.length === 0) return
|
||||
const currentIndex = enabledOptions.findIndex((o) => o.value === activeValue)
|
||||
const nextIndex =
|
||||
currentIndex === -1 ? 0 : Math.min(Math.max(currentIndex + direction, 0), enabledOptions.length - 1)
|
||||
setActiveValue(enabledOptions[nextIndex].value)
|
||||
}
|
||||
|
||||
function commitActive() {
|
||||
if (activeValue !== null) onChange(activeValue)
|
||||
closePanel()
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
|
||||
function selectOption(option: SelectOption) {
|
||||
if (option.disabled) return
|
||||
onChange(option.value)
|
||||
closePanel()
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
|
||||
function handleTypeahead(char: string) {
|
||||
const buf = typeaheadRef.current
|
||||
window.clearTimeout(buf.timer)
|
||||
buf.text += char.toLowerCase()
|
||||
buf.timer = window.setTimeout(() => {
|
||||
buf.text = ''
|
||||
}, 600)
|
||||
const currentIndex = enabledOptions.findIndex((o) => o.value === activeValue)
|
||||
// Ищем от следующего после подсвеченного, по кругу — как в нативном select.
|
||||
const ordered = [...enabledOptions.slice(currentIndex + 1), ...enabledOptions.slice(0, currentIndex + 1)]
|
||||
const match = ordered.find((o) => o.label.toLowerCase().startsWith(buf.text))
|
||||
if (match) setActiveValue(match.value)
|
||||
}
|
||||
|
||||
function handleTriggerKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
|
||||
if (disabled) return
|
||||
|
||||
if (!open) {
|
||||
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
openPanel()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
moveActive(1)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
moveActive(-1)
|
||||
break
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
if (enabledOptions[0]) setActiveValue(enabledOptions[0].value)
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
if (enabledOptions.length > 0) setActiveValue(enabledOptions[enabledOptions.length - 1].value)
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
commitActive()
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
closePanel()
|
||||
break
|
||||
case 'Tab':
|
||||
closePanel()
|
||||
break
|
||||
default:
|
||||
if (event.key.length === 1) handleTypeahead(event.key)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vc-select" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
ref={triggerRef}
|
||||
id={baseId}
|
||||
className={`vc-select-trigger${error ? ' has-error' : ''}`}
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={open && activeValue !== null ? `${listboxId}-opt-${activeValue}` : undefined}
|
||||
aria-disabled={disabled || undefined}
|
||||
disabled={disabled}
|
||||
onClick={() => (open ? closePanel() : openPanel())}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
{...aria}
|
||||
>
|
||||
{error && <AlertCircle className="vc-select-error-icon" style={{ width: 16, height: 16 }} aria-hidden="true" />}
|
||||
<span className={selectedOption ? 'vc-select-value' : 'vc-select-placeholder'}>
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
</span>
|
||||
<ChevronDown className={`vc-select-chevron${open ? ' is-open' : ''}`} style={{ width: 18, height: 18 }} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul className="vc-select-panel" role="listbox" id={listboxId}>
|
||||
{options.map((option) => {
|
||||
const isSelected = option.value === value
|
||||
const isActive = option.value === activeValue
|
||||
return (
|
||||
<li
|
||||
key={option.value}
|
||||
id={`${listboxId}-opt-${option.value}`}
|
||||
ref={(el) => {
|
||||
if (el) optionRefs.current.set(option.value, el)
|
||||
else optionRefs.current.delete(option.value)
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled || undefined}
|
||||
className={`vc-select-option${isActive ? ' is-active' : ''}${isSelected ? ' is-selected' : ''}${option.disabled ? ' is-disabled' : ''}`}
|
||||
onMouseEnter={() => !option.disabled && setActiveValue(option.value)}
|
||||
onClick={() => selectOption(option)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{isSelected && <Check style={{ width: 16, height: 16 }} aria-hidden="true" />}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
frontend/src/components/ui/ThemeToggle.tsx
Normal file
34
frontend/src/components/ui/ThemeToggle.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from '@/hooks/useTheme'
|
||||
|
||||
/**
|
||||
* Переключатель темы оболочки «солнце/луна» (см. design/mockups/dark/*.html,
|
||||
* `.theme-toggle`). Комнату конференции не затрагивает — она всегда на своей
|
||||
* теме `[data-theme="room"]` (см. RoomPage.tsx), не участвующей в переключении.
|
||||
*/
|
||||
export function ThemeToggle({ className = '' }: { className?: string }) {
|
||||
const { resolved, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<div className={`theme-toggle${className ? ` ${className}` : ''}`} role="group" aria-label="Переключить тему оформления">
|
||||
<button
|
||||
type="button"
|
||||
className={`theme-toggle-btn${resolved === 'light' ? ' is-active' : ''}`}
|
||||
aria-pressed={resolved === 'light'}
|
||||
aria-label="Светлая тема"
|
||||
onClick={() => setTheme('light')}
|
||||
>
|
||||
<Sun className="icon" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`theme-toggle-btn${resolved === 'dark' ? ' is-active' : ''}`}
|
||||
aria-pressed={resolved === 'dark'}
|
||||
aria-label="Тёмная тема"
|
||||
onClick={() => setTheme('dark')}
|
||||
>
|
||||
<Moon className="icon" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
frontend/src/components/ui/ToastProvider.tsx
Normal file
63
frontend/src/components/ui/ToastProvider.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
|
||||
import { AlertCircle, CheckCircle2, Info } from 'lucide-react'
|
||||
|
||||
type ToastVariant = 'error' | 'success' | 'info'
|
||||
|
||||
interface ToastItem {
|
||||
id: number
|
||||
message: string
|
||||
variant: ToastVariant
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
show: (message: string, variant?: ToastVariant) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
const ICON_BY_VARIANT: Record<ToastVariant, typeof AlertCircle> = {
|
||||
error: AlertCircle,
|
||||
success: CheckCircle2,
|
||||
info: Info,
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
|
||||
/** Лёгкий провайдер тостов (без внешней библиотеки) по токенам дизайн-системы. */
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
|
||||
const show = useCallback((message: string, variant: ToastVariant = 'info') => {
|
||||
const id = nextId++
|
||||
setToasts((prev) => [...prev, { id, message, variant }])
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, 4000)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => ({ show }), [show])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className="toast-stack" role="status" aria-live="polite">
|
||||
{toasts.map((toast) => {
|
||||
const Icon = ICON_BY_VARIANT[toast.variant]
|
||||
return (
|
||||
<div key={toast.id} className={`toast toast-${toast.variant}`}>
|
||||
<Icon className="lucide" style={{ width: 18, height: 18 }} aria-hidden="true" />
|
||||
{toast.message}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/** Хук показа тостов. Должен использоваться внутри ToastProvider. */
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext)
|
||||
if (!ctx) throw new Error('useToast должен использоваться внутри <ToastProvider>')
|
||||
return ctx
|
||||
}
|
||||
58
frontend/src/components/ui/button.tsx
Normal file
58
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
Reference in New Issue
Block a user