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