`AppFooter` — версия инстанса из публичного `/api/health` (не требует авторизации, поэтому работает и на экранах входа; поля `version` может не быть — тогда версия не показывается) плюс копирайт с ТЕКУЩИМ годом и ссылкой на профиль автора. Подключён во все раскладки: `page-shell` (календарь, админка, профиль, мои конференции), `AuthLayout` (вход, регистрация, подтверждение email), `join-shell` и `lobby-shell`. В комнате конференции футера нет — там полноэкранное видео. Прежний футер админки (`.admin-footer`) удалён вместе со стилем — дубля не осталось. В брендовой панели экранов auth строка «© 2026 VidConf · self-hosted» ужата до «self-hosted»: год там был захардкожен, а два копирайта на одном экране — дубль.
154 lines
6.8 KiB
TypeScript
154 lines
6.8 KiB
TypeScript
import { useCallback, useMemo, useState } from 'react'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import { Plus } from 'lucide-react'
|
||
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
||
import { AppFooter } from '@/components/layout/AppFooter'
|
||
import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
|
||
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
|
||
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
|
||
import { useModalDismiss } from '@/hooks/useModalDismiss'
|
||
import { getCalendarOccurrences, getMyConferences, type ConferenceOut, type OccurrenceOut } from '@/api/conferences'
|
||
import { pluralizeConferences } from '@/lib/pluralize'
|
||
import '@/styles/lobby.css'
|
||
import '@/styles/calendar.css'
|
||
|
||
interface VisibleRange {
|
||
fromIso: string
|
||
toIso: string
|
||
}
|
||
|
||
/** Стабильный дефолт для пропса `occurrences` `ConferenceCalendar` (пока `useQuery` не загрузил данные) — см. комментарий в ConferenceCalendar.tsx про identity пропсов FullCalendar. */
|
||
const EMPTY_OCCURRENCES: OccurrenceOut[] = []
|
||
|
||
/**
|
||
* Календарь-менеджер запланированных/закреплённых конференций
|
||
* (см. design/mockups/calendar.html).
|
||
*
|
||
* Форма создания/редактирования (`ConferenceFormCard`) живёт в модалке, не в
|
||
* сетке страницы — календарь занимает всю ширину. Клик по чипу (в т.ч. по
|
||
* своей конференции) открывает информационное окно (`ConferenceOccurrenceDialog`);
|
||
* редактирование — отдельное действие: карандашик на чипе (только у
|
||
* владельца, см. `myConferenceById`) или кнопка «Редактировать» в самом
|
||
* информационном окне.
|
||
*/
|
||
export function CalendarPage() {
|
||
const [view, setView] = useState<CalendarViewMode>('dayGridWeek')
|
||
const [range, setRange] = useState<VisibleRange | null>(null)
|
||
const [isFormOpen, setIsFormOpen] = useState(false)
|
||
const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null)
|
||
const [occurrenceDialogTarget, setOccurrenceDialogTarget] = useState<OccurrenceOut | null>(null)
|
||
|
||
const { data: occurrences, isLoading: occurrencesLoading } = useQuery({
|
||
queryKey: ['conferences', 'calendar', range?.fromIso, range?.toIso],
|
||
queryFn: () => getCalendarOccurrences(range!.fromIso, range!.toIso),
|
||
enabled: !!range,
|
||
})
|
||
|
||
const { data: myConferences } = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences })
|
||
const myConferenceById = useMemo(() => new Map((myConferences ?? []).map((c) => [c.id, c])), [myConferences])
|
||
|
||
const openCreateForm = useCallback(() => {
|
||
setEditingConference(null)
|
||
setIsFormOpen(true)
|
||
}, [])
|
||
|
||
const openEditForm = useCallback((conference: ConferenceOut) => {
|
||
setEditingConference(conference)
|
||
setIsFormOpen(true)
|
||
setOccurrenceDialogTarget(null)
|
||
}, [])
|
||
|
||
const closeForm = useCallback(() => setIsFormOpen(false), [])
|
||
|
||
// Бэйл-аут по значению (не по ссылке): FullCalendar вызывает `datesSet`
|
||
// повторно всякий раз, когда меняется identity его пропсов (см. подробный
|
||
// комментарий в ConferenceCalendar.tsx). Если диапазон фактически не
|
||
// изменился, `setRange` возвращает тот же объект — React пропускает
|
||
// ре-рендер (Object.is-бэйлаут), и цикл не запускается даже если
|
||
// `datesSet` дёрнется ещё раз вхолостую.
|
||
const handleRangeChange = useCallback((fromIso: string, toIso: string) => {
|
||
setRange((prev) => (prev && prev.fromIso === fromIso && prev.toIso === toIso ? prev : { fromIso, toIso }))
|
||
}, [])
|
||
|
||
const dialogOwnedConference = occurrenceDialogTarget ? myConferenceById.get(occurrenceDialogTarget.conference_id) : undefined
|
||
|
||
return (
|
||
<div className="page-shell">
|
||
<ShellTopbar />
|
||
<main className="page-main">
|
||
<div className="page-head">
|
||
<div>
|
||
<h1>Календарь конференций</h1>
|
||
<p>
|
||
{occurrences?.length ?? 0} {pluralizeConferences(occurrences?.length ?? 0)} в выбранном периоде — запланируйте
|
||
разовую встречу или закрепите постоянную конференцию с повторением
|
||
</p>
|
||
</div>
|
||
<button type="button" className="btn btn-primary" onClick={openCreateForm}>
|
||
<Plus style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||
Новая конференция
|
||
</button>
|
||
</div>
|
||
|
||
<div className="layout-grid">
|
||
<ConferenceCalendar
|
||
view={view}
|
||
onViewChange={setView}
|
||
occurrences={occurrences ?? EMPTY_OCCURRENCES}
|
||
isLoading={occurrencesLoading}
|
||
myConferenceById={myConferenceById}
|
||
onRangeChange={handleRangeChange}
|
||
onOccurrenceClick={setOccurrenceDialogTarget}
|
||
onEditClick={openEditForm}
|
||
/>
|
||
</div>
|
||
</main>
|
||
<AppFooter />
|
||
|
||
{isFormOpen && (
|
||
<ConferenceFormModal
|
||
conference={editingConference}
|
||
onSaved={closeForm}
|
||
onDeleted={closeForm}
|
||
onClose={closeForm}
|
||
/>
|
||
)}
|
||
|
||
{occurrenceDialogTarget && (
|
||
<ConferenceOccurrenceDialog
|
||
occurrence={occurrenceDialogTarget}
|
||
ownedConference={dialogOwnedConference?.is_owner ? dialogOwnedConference : undefined}
|
||
onClose={() => setOccurrenceDialogTarget(null)}
|
||
onEdit={openEditForm}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
interface ConferenceFormModalProps {
|
||
conference: ConferenceOut | null
|
||
onSaved: () => void
|
||
onDeleted: () => void
|
||
onClose: () => void
|
||
}
|
||
|
||
/** Модалка создания/редактирования — разметка по образцу `ConferenceOccurrenceDialog` (modal-overlay/modal-panel, закрытие по клику на оверлей, Escape и возврат фокуса). */
|
||
function ConferenceFormModal({ conference, onSaved, onDeleted, onClose }: ConferenceFormModalProps) {
|
||
useModalDismiss(onClose)
|
||
|
||
return (
|
||
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={onClose}>
|
||
<div className="modal-form-wrap" onClick={(e) => e.stopPropagation()}>
|
||
<ConferenceFormCard
|
||
key={conference?.id ?? 'create'}
|
||
conference={conference ?? undefined}
|
||
onSaved={onSaved}
|
||
onDeleted={onDeleted}
|
||
onCancelEdit={onClose}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|