refactor(calendar): форма создания/редактирования конференции — в модальное окно
Раньше ConferenceFormCard всегда висела в сайдбаре двухколоночной сетки .layout-grid; теперь открывается в модалке (по образцу ConferenceOccurrenceDialog: modal-overlay/modal-panel, закрытие по клику на оверлей, Escape и возврат фокуса — новый общий хук useModalDismiss). .layout-grid стала одноколоночной — календарь занимает всю ширину.
This commit is contained in:
32
frontend/src/hooks/useModalDismiss.ts
Normal file
32
frontend/src/hooks/useModalDismiss.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Общее поведение модалок оболочки (`.modal-overlay`/`.modal-panel`):
|
||||
* закрытие по Escape и возврат фокуса на элемент, который был активен до
|
||||
* открытия модалки (обычно кнопка, её вызвавшая) — доступность по клавиатуре.
|
||||
*/
|
||||
export function useModalDismiss(onClose: () => void) {
|
||||
const previouslyFocused = useRef<Element | null>(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()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -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<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)
|
||||
|
||||
@@ -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 (
|
||||
<div className="page-shell">
|
||||
<ShellTopbar />
|
||||
@@ -83,7 +83,7 @@ export function CalendarPage() {
|
||||
разовую встречу или закрепите постоянную конференцию с повторением
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setEditingConference(null)}>
|
||||
<button type="button" className="btn btn-primary" onClick={openCreateForm}>
|
||||
<Plus style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
Новая конференция
|
||||
</button>
|
||||
@@ -95,23 +95,57 @@ export function CalendarPage() {
|
||||
onViewChange={setView}
|
||||
occurrences={occurrences ?? EMPTY_OCCURRENCES}
|
||||
isLoading={occurrencesLoading}
|
||||
myConferenceById={myConferenceById}
|
||||
onRangeChange={handleRangeChange}
|
||||
onOccurrenceClick={handleOccurrenceClick}
|
||||
/>
|
||||
|
||||
<ConferenceFormCard
|
||||
key={editingConference?.id ?? 'create'}
|
||||
conference={editingConference ?? undefined}
|
||||
onSaved={() => setEditingConference(null)}
|
||||
onDeleted={() => setEditingConference(null)}
|
||||
onCancelEdit={() => setEditingConference(null)}
|
||||
onOccurrenceClick={setOccurrenceDialogTarget}
|
||||
onEditClick={openEditForm}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{isFormOpen && (
|
||||
<ConferenceFormModal
|
||||
conference={editingConference}
|
||||
onSaved={closeForm}
|
||||
onDeleted={closeForm}
|
||||
onClose={closeForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{occurrenceDialogTarget && (
|
||||
<ConferenceOccurrenceDialog occurrence={occurrenceDialogTarget} onClose={() => setOccurrenceDialogTarget(null)} />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
/* Узкий экран: «Удалить» + «Отмена» + «Сохранить» в один ряд не помещаются
|
||||
(в сумме шире содержимого модалки на телефоне) — переносим «Удалить» на
|
||||
свою строку сверху. */
|
||||
|
||||
Reference in New Issue
Block a user