Первоначальная версия 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,117 @@
import { useCallback, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Plus } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
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).
*
* Редактирование в сайдбаре доступно только для «своих» вхождений — тех,
* что присутствуют в `/conferences/my` (закреплённые и предстоящие разовые
* владельца; там же полная модель — recurrence/duration, которых нет в
* `OccurrenceOut` календарной выборки). Остальные вхождения по клику
* открывают лёгкий информационный диалог с входом и ссылкой на «Мои
* конференции» — согласованное отклонение от макета, вызванное отсутствием
* в контракте эндпоинта получения одной конференции по id.
*/
export function CalendarPage() {
const [view, setView] = useState<CalendarViewMode>('dayGridWeek')
const [range, setRange] = useState<VisibleRange | null>(null)
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 handleOccurrenceClick = useCallback(
(occurrence: OccurrenceOut) => {
// `/conferences/my` может содержать и конференции, куда пользователь
// только приглашён участником (не организатор) — редактирование
// открываем только владельцу, иначе — тот же
// информационный диалог, что и для чужих вхождений.
const owned = myConferenceById.get(occurrence.conference_id)
if (owned?.is_owner) {
setEditingConference(owned)
} else {
setOccurrenceDialogTarget(occurrence)
}
},
[myConferenceById],
)
// Бэйл-аут по значению (не по ссылке): 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 }))
}, [])
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={() => setEditingConference(null)}>
<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}
onRangeChange={handleRangeChange}
onOccurrenceClick={handleOccurrenceClick}
/>
<ConferenceFormCard
key={editingConference?.id ?? 'create'}
conference={editingConference ?? undefined}
onSaved={() => setEditingConference(null)}
onDeleted={() => setEditingConference(null)}
onCancelEdit={() => setEditingConference(null)}
/>
</div>
</main>
{occurrenceDialogTarget && (
<ConferenceOccurrenceDialog occurrence={occurrenceDialogTarget} onClose={() => setOccurrenceDialogTarget(null)} />
)}
</div>
)
}