Первоначальная версия VidConf
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user