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

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

View File

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

View File

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

View File

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

View File

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