From 07e5aba9c9afee7ce6bef5fab4f34d9c4f12c880 Mon Sep 17 00:00:00 2001 From: Max Ronzhin Date: Sun, 26 Jul 2026 23:33:52 +0300 Subject: [PATCH] =?UTF-8?q?refactor(calendar):=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F/?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D1=86=D0=B8=D0=B8=20=E2=80=94=20=D0=B2=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D0=BE=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше ConferenceFormCard всегда висела в сайдбаре двухколоночной сетки .layout-grid; теперь открывается в модалке (по образцу ConferenceOccurrenceDialog: modal-overlay/modal-panel, закрытие по клику на оверлей, Escape и возврат фокуса — новый общий хук useModalDismiss). .layout-grid стала одноколоночной — календарь занимает всю ширину. --- frontend/src/hooks/useModalDismiss.ts | 32 ++++++++ frontend/src/pages/CalendarPage.tsx | 100 +++++++++++++++++-------- frontend/src/styles/calendar.css | 29 ++++--- frontend/src/styles/my-conferences.css | 7 -- frontend/src/styles/shell.css | 8 ++ 5 files changed, 120 insertions(+), 56 deletions(-) create mode 100644 frontend/src/hooks/useModalDismiss.ts diff --git a/frontend/src/hooks/useModalDismiss.ts b/frontend/src/hooks/useModalDismiss.ts new file mode 100644 index 0000000..4fdc6a8 --- /dev/null +++ b/frontend/src/hooks/useModalDismiss.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef } from 'react' + +/** + * Общее поведение модалок оболочки (`.modal-overlay`/`.modal-panel`): + * закрытие по Escape и возврат фокуса на элемент, который был активен до + * открытия модалки (обычно кнопка, её вызвавшая) — доступность по клавиатуре. + */ +export function useModalDismiss(onClose: () => void) { + const previouslyFocused = useRef(null) + // Всегда актуальный колбэк без пересоздания подписки на keydown при каждом + // рендере (onClose у вызывающих обычно инлайновая функция). Синхронизация — + // отдельным эффектом (не прямым присваиванием в теле рендера, см. + // react-hooks/refs — рефы нельзя трогать во время рендера). + const onCloseRef = useRef(onClose) + useEffect(() => { + onCloseRef.current = onClose + }) + + useEffect(() => { + previouslyFocused.current = document.activeElement + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') onCloseRef.current() + } + document.addEventListener('keydown', handleKeyDown) + + return () => { + document.removeEventListener('keydown', handleKeyDown) + if (previouslyFocused.current instanceof HTMLElement) previouslyFocused.current.focus() + } + }, []) +} diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx index e02ab4f..d298b9b 100644 --- a/frontend/src/pages/CalendarPage.tsx +++ b/frontend/src/pages/CalendarPage.tsx @@ -5,6 +5,7 @@ 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 { useModalDismiss } from '@/hooks/useModalDismiss' import { getCalendarOccurrences, getMyConferences, type ConferenceOut, type OccurrenceOut } from '@/api/conferences' import { pluralizeConferences } from '@/lib/pluralize' import '@/styles/lobby.css' @@ -22,17 +23,17 @@ const EMPTY_OCCURRENCES: OccurrenceOut[] = [] * Календарь-менеджер запланированных/закреплённых конференций * (см. design/mockups/calendar.html). * - * Редактирование в сайдбаре доступно только для «своих» вхождений — тех, - * что присутствуют в `/conferences/my` (закреплённые и предстоящие разовые - * владельца; там же полная модель — recurrence/duration, которых нет в - * `OccurrenceOut` календарной выборки). Остальные вхождения по клику - * открывают лёгкий информационный диалог с входом и ссылкой на «Мои - * конференции» — согласованное отклонение от макета, вызванное отсутствием - * в контракте эндпоинта получения одной конференции по id. + * Форма создания/редактирования (`ConferenceFormCard`) живёт в модалке, не в + * сетке страницы — календарь занимает всю ширину. Клик по чипу (в т.ч. по + * своей конференции) открывает информационное окно (`ConferenceOccurrenceDialog`); + * редактирование — отдельное действие: карандашик на чипе (только у + * владельца, см. `myConferenceById`) или кнопка «Редактировать» в самом + * информационном окне. */ export function CalendarPage() { const [view, setView] = useState('dayGridWeek') const [range, setRange] = useState(null) + const [isFormOpen, setIsFormOpen] = useState(false) const [editingConference, setEditingConference] = useState(null) const [occurrenceDialogTarget, setOccurrenceDialogTarget] = useState(null) @@ -45,21 +46,18 @@ export function CalendarPage() { 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], - ) + 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 его пропсов (см. подробный @@ -71,6 +69,8 @@ export function CalendarPage() { setRange((prev) => (prev && prev.fromIso === fromIso && prev.toIso === toIso ? prev : { fromIso, toIso })) }, []) + const dialogOwnedConference = occurrenceDialogTarget ? myConferenceById.get(occurrenceDialogTarget.conference_id) : undefined + return (
@@ -83,7 +83,7 @@ export function CalendarPage() { разовую встречу или закрепите постоянную конференцию с повторением

- @@ -95,23 +95,57 @@ export function CalendarPage() { onViewChange={setView} occurrences={occurrences ?? EMPTY_OCCURRENCES} isLoading={occurrencesLoading} + myConferenceById={myConferenceById} onRangeChange={handleRangeChange} - onOccurrenceClick={handleOccurrenceClick} - /> - - setEditingConference(null)} - onDeleted={() => setEditingConference(null)} - onCancelEdit={() => setEditingConference(null)} + onOccurrenceClick={setOccurrenceDialogTarget} + onEditClick={openEditForm} /> + {isFormOpen && ( + + )} + {occurrenceDialogTarget && ( - setOccurrenceDialogTarget(null)} /> + setOccurrenceDialogTarget(null)} + onEdit={openEditForm} + /> )} ) } + +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 ( +
+
e.stopPropagation()}> + +
+
+ ) +} diff --git a/frontend/src/styles/calendar.css b/frontend/src/styles/calendar.css index 18841e8..bd763c3 100644 --- a/frontend/src/styles/calendar.css +++ b/frontend/src/styles/calendar.css @@ -7,17 +7,20 @@ * (см. комментарий в ConferenceCalendar.tsx). */ -/* ---------- Грид: сетка вхождений + карточка создания/редактирования ---------- */ +/* ---------- Календарь во всю ширину контента ---------- */ +/* Раньше `.layout-grid` была двухколоночной сеткой (календарь + вечно + висящая сбоку `.create-card`) — форма создания/редактирования переехала в + модалку (`.modal-form-wrap`, см. my-conferences.css), сайдбара больше нет. + `min-width:0` оставлен: `.week-card` внутри — единственный элемент грида, и + без этого грид-трек не сжимается уже своего min-content (внутренняя таблица + FullCalendar с 5 колонками) — классический grid blowout, раздувающий трек и + всю страницу шире вьюпорта (см. тот же приём в lobby.css/.action). */ .layout-grid { display: grid; - grid-template-columns: 1fr 400px; + grid-template-columns: 1fr; gap: var(--space-6); align-items: start; } -/* min-width:0 — без этого грид-элемент с 1fr-треком не сжимается уже своего - min-content (тут это внутренняя таблица FullCalendar с 5 колонками) — - классический grid blowout: трек и вся страница раздуваются шире вьюпорта, - появляется горизонтальный скролл (см. тот же приём в lobby.css/.action). */ .layout-grid > * { min-width: 0; } .week-card { @@ -242,17 +245,11 @@ совпадения media query (тот же баг уже ловили в lobby.css/.action, гейт 3 — см. комментарий там). Макет calendar.html — desktop-only (`html { min-width: 1280px }`, DESIGN_SYSTEM.md §«Breakpoints»), адаптива - в нём нет — раскладка ниже подобрана по здравому смыслу: календарь сверху, - карточка создания/редактирования — под ним (сохраняем порядок вёрстки). + в нём нет. + .layout-grid уже одноколоночная на всех ширинах (§2.2) — двухколоночного + 960px-брейкпоинта под сайдбар-форму больше нет, форма теперь в модалке + (.modal-form-wrap .create-card в my-conferences.css гасит sticky сама). */ -@media (max-width: 960px) { - /* Двухколоночный грид (сетка + сайдбар 400px) → одна колонка, сайдбар - перестаёт быть sticky (в потоке одной колонки залипание при скролле - страницы выглядит и работает хуже, чем простое следование за контентом). */ - .layout-grid { grid-template-columns: 1fr; } - .create-card { position: static; top: auto; } -} - @media (max-width: 640px) { .week-card, .create-card { padding: var(--space-4); } .week-toolbar { flex-direction: column; align-items: stretch; } diff --git a/frontend/src/styles/my-conferences.css b/frontend/src/styles/my-conferences.css index c985914..28cccae 100644 --- a/frontend/src/styles/my-conferences.css +++ b/frontend/src/styles/my-conferences.css @@ -52,13 +52,6 @@ .icon-btn { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-ink-500); display: flex; align-items: center; justify-content: center; cursor: pointer; } .icon-btn:hover { border-color: var(--color-border-strong); color: var(--color-ink-900); } -/* ---------- Модалка редактирования (переиспользует ConferenceFormCard) ---------- */ -/* .create-card сама по себе — визуальная панель (фон/обводка/радиус/паддинг из - calendar.css); здесь только гасим её sticky-позиционирование календаря и - ограничиваем высоту внутри модалки. */ -.modal-form-wrap { width: 100%; max-width: 480px; } -.modal-form-wrap .create-card { position: static; max-height: calc(100vh - var(--space-12)); overflow-y: auto; } - /* Тёмная тема: тултип «Скопировано» — заливка была var(--color-ink-900) (тёмный индиго в светлой теме → стал бы светло-серым в тёмной, ломая тёмный тултип поверх тёмной страницы); заменена на буквальный #333333 diff --git a/frontend/src/styles/shell.css b/frontend/src/styles/shell.css index fdd31e3..a0bf2f2 100644 --- a/frontend/src/styles/shell.css +++ b/frontend/src/styles/shell.css @@ -417,6 +417,14 @@ .modal-actions--split .modal-actions-group { display: flex; gap: var(--space-3); } .modal-actions--split .row-actions { display: flex; align-items: center; gap: var(--space-3); } +/* Модалка вокруг ConferenceFormCard (`.create-card`, «Мои конференции» и + календарь) — сама карточка остаётся визуальной панелью + (фон/обводка/радиус/паддинг из calendar.css), здесь только гасим её + sticky-позиционирование из десктопного сайдбара и ограничиваем высоту + внутри модалки, чтобы скроллилась она сама, а не вся страница. */ +.modal-form-wrap { width: 100%; max-width: 480px; } +.modal-form-wrap .create-card { position: static; max-height: calc(100vh - var(--space-12)); overflow-y: auto; } + /* Узкий экран: «Удалить» + «Отмена» + «Сохранить» в один ряд не помещаются (в сумме шире содержимого модалки на телефоне) — переносим «Удалить» на свою строку сверху. */