Files
vidconf/frontend/src/pages/CalendarPage.tsx
Max Ronzhin b6f57384b0 feat(ui): общий футер на всех страницах, кроме комнаты
`AppFooter` — версия инстанса из публичного `/api/health` (не требует
авторизации, поэтому работает и на экранах входа; поля `version` может не
быть — тогда версия не показывается) плюс копирайт с ТЕКУЩИМ годом и
ссылкой на профиль автора.

Подключён во все раскладки: `page-shell` (календарь, админка, профиль, мои
конференции), `AuthLayout` (вход, регистрация, подтверждение email),
`join-shell` и `lobby-shell`. В комнате конференции футера нет — там
полноэкранное видео.

Прежний футер админки (`.admin-footer`) удалён вместе со стилем — дубля не
осталось. В брендовой панели экранов auth строка «© 2026 VidConf ·
self-hosted» ужата до «self-hosted»: год там был захардкожен, а два
копирайта на одном экране — дубль.
2026-07-27 01:31:52 +03:00

154 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}