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 { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
|
||||||
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
|
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
|
||||||
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
|
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
|
||||||
|
import { useModalDismiss } from '@/hooks/useModalDismiss'
|
||||||
import { getCalendarOccurrences, getMyConferences, type ConferenceOut, type OccurrenceOut } from '@/api/conferences'
|
import { getCalendarOccurrences, getMyConferences, type ConferenceOut, type OccurrenceOut } from '@/api/conferences'
|
||||||
import { pluralizeConferences } from '@/lib/pluralize'
|
import { pluralizeConferences } from '@/lib/pluralize'
|
||||||
import '@/styles/lobby.css'
|
import '@/styles/lobby.css'
|
||||||
@@ -22,17 +23,17 @@ const EMPTY_OCCURRENCES: OccurrenceOut[] = []
|
|||||||
* Календарь-менеджер запланированных/закреплённых конференций
|
* Календарь-менеджер запланированных/закреплённых конференций
|
||||||
* (см. design/mockups/calendar.html).
|
* (см. design/mockups/calendar.html).
|
||||||
*
|
*
|
||||||
* Редактирование в сайдбаре доступно только для «своих» вхождений — тех,
|
* Форма создания/редактирования (`ConferenceFormCard`) живёт в модалке, не в
|
||||||
* что присутствуют в `/conferences/my` (закреплённые и предстоящие разовые
|
* сетке страницы — календарь занимает всю ширину. Клик по чипу (в т.ч. по
|
||||||
* владельца; там же полная модель — recurrence/duration, которых нет в
|
* своей конференции) открывает информационное окно (`ConferenceOccurrenceDialog`);
|
||||||
* `OccurrenceOut` календарной выборки). Остальные вхождения по клику
|
* редактирование — отдельное действие: карандашик на чипе (только у
|
||||||
* открывают лёгкий информационный диалог с входом и ссылкой на «Мои
|
* владельца, см. `myConferenceById`) или кнопка «Редактировать» в самом
|
||||||
* конференции» — согласованное отклонение от макета, вызванное отсутствием
|
* информационном окне.
|
||||||
* в контракте эндпоинта получения одной конференции по id.
|
|
||||||
*/
|
*/
|
||||||
export function CalendarPage() {
|
export function CalendarPage() {
|
||||||
const [view, setView] = useState<CalendarViewMode>('dayGridWeek')
|
const [view, setView] = useState<CalendarViewMode>('dayGridWeek')
|
||||||
const [range, setRange] = useState<VisibleRange | null>(null)
|
const [range, setRange] = useState<VisibleRange | null>(null)
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false)
|
||||||
const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null)
|
const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null)
|
||||||
const [occurrenceDialogTarget, setOccurrenceDialogTarget] = useState<OccurrenceOut | 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 { data: myConferences } = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences })
|
||||||
const myConferenceById = useMemo(() => new Map((myConferences ?? []).map((c) => [c.id, c])), [myConferences])
|
const myConferenceById = useMemo(() => new Map((myConferences ?? []).map((c) => [c.id, c])), [myConferences])
|
||||||
|
|
||||||
const handleOccurrenceClick = useCallback(
|
const openCreateForm = useCallback(() => {
|
||||||
(occurrence: OccurrenceOut) => {
|
setEditingConference(null)
|
||||||
// `/conferences/my` может содержать и конференции, куда пользователь
|
setIsFormOpen(true)
|
||||||
// только приглашён участником (не организатор) — редактирование
|
}, [])
|
||||||
// открываем только владельцу, иначе — тот же
|
|
||||||
// информационный диалог, что и для чужих вхождений.
|
const openEditForm = useCallback((conference: ConferenceOut) => {
|
||||||
const owned = myConferenceById.get(occurrence.conference_id)
|
setEditingConference(conference)
|
||||||
if (owned?.is_owner) {
|
setIsFormOpen(true)
|
||||||
setEditingConference(owned)
|
setOccurrenceDialogTarget(null)
|
||||||
} else {
|
}, [])
|
||||||
setOccurrenceDialogTarget(occurrence)
|
|
||||||
}
|
const closeForm = useCallback(() => setIsFormOpen(false), [])
|
||||||
},
|
|
||||||
[myConferenceById],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Бэйл-аут по значению (не по ссылке): FullCalendar вызывает `datesSet`
|
// Бэйл-аут по значению (не по ссылке): FullCalendar вызывает `datesSet`
|
||||||
// повторно всякий раз, когда меняется identity его пропсов (см. подробный
|
// повторно всякий раз, когда меняется identity его пропсов (см. подробный
|
||||||
@@ -71,6 +69,8 @@ export function CalendarPage() {
|
|||||||
setRange((prev) => (prev && prev.fromIso === fromIso && prev.toIso === toIso ? prev : { fromIso, toIso }))
|
setRange((prev) => (prev && prev.fromIso === fromIso && prev.toIso === toIso ? prev : { fromIso, toIso }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const dialogOwnedConference = occurrenceDialogTarget ? myConferenceById.get(occurrenceDialogTarget.conference_id) : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-shell">
|
<div className="page-shell">
|
||||||
<ShellTopbar />
|
<ShellTopbar />
|
||||||
@@ -83,7 +83,7 @@ export function CalendarPage() {
|
|||||||
разовую встречу или закрепите постоянную конференцию с повторением
|
разовую встречу или закрепите постоянную конференцию с повторением
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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" />
|
<Plus style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||||
Новая конференция
|
Новая конференция
|
||||||
</button>
|
</button>
|
||||||
@@ -95,23 +95,57 @@ export function CalendarPage() {
|
|||||||
onViewChange={setView}
|
onViewChange={setView}
|
||||||
occurrences={occurrences ?? EMPTY_OCCURRENCES}
|
occurrences={occurrences ?? EMPTY_OCCURRENCES}
|
||||||
isLoading={occurrencesLoading}
|
isLoading={occurrencesLoading}
|
||||||
|
myConferenceById={myConferenceById}
|
||||||
onRangeChange={handleRangeChange}
|
onRangeChange={handleRangeChange}
|
||||||
onOccurrenceClick={handleOccurrenceClick}
|
onOccurrenceClick={setOccurrenceDialogTarget}
|
||||||
/>
|
onEditClick={openEditForm}
|
||||||
|
|
||||||
<ConferenceFormCard
|
|
||||||
key={editingConference?.id ?? 'create'}
|
|
||||||
conference={editingConference ?? undefined}
|
|
||||||
onSaved={() => setEditingConference(null)}
|
|
||||||
onDeleted={() => setEditingConference(null)}
|
|
||||||
onCancelEdit={() => setEditingConference(null)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{isFormOpen && (
|
||||||
|
<ConferenceFormModal
|
||||||
|
conference={editingConference}
|
||||||
|
onSaved={closeForm}
|
||||||
|
onDeleted={closeForm}
|
||||||
|
onClose={closeForm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{occurrenceDialogTarget && (
|
{occurrenceDialogTarget && (
|
||||||
<ConferenceOccurrenceDialog occurrence={occurrenceDialogTarget} onClose={() => setOccurrenceDialogTarget(null)} />
|
<ConferenceOccurrenceDialog
|
||||||
|
occurrence={occurrenceDialogTarget}
|
||||||
|
ownedConference={dialogOwnedConference?.is_owner ? dialogOwnedConference : undefined}
|
||||||
|
onClose={() => setOccurrenceDialogTarget(null)}
|
||||||
|
onEdit={openEditForm}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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).
|
* (см. комментарий в 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 {
|
.layout-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 400px;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
/* min-width:0 — без этого грид-элемент с 1fr-треком не сжимается уже своего
|
|
||||||
min-content (тут это внутренняя таблица FullCalendar с 5 колонками) —
|
|
||||||
классический grid blowout: трек и вся страница раздуваются шире вьюпорта,
|
|
||||||
появляется горизонтальный скролл (см. тот же приём в lobby.css/.action). */
|
|
||||||
.layout-grid > * { min-width: 0; }
|
.layout-grid > * { min-width: 0; }
|
||||||
|
|
||||||
.week-card {
|
.week-card {
|
||||||
@@ -242,17 +245,11 @@
|
|||||||
совпадения media query (тот же баг уже ловили в lobby.css/.action, гейт 3
|
совпадения media query (тот же баг уже ловили в lobby.css/.action, гейт 3
|
||||||
— см. комментарий там). Макет calendar.html — desktop-only
|
— см. комментарий там). Макет calendar.html — desktop-only
|
||||||
(`html { min-width: 1280px }`, DESIGN_SYSTEM.md §«Breakpoints»), адаптива
|
(`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) {
|
@media (max-width: 640px) {
|
||||||
.week-card, .create-card { padding: var(--space-4); }
|
.week-card, .create-card { padding: var(--space-4); }
|
||||||
.week-toolbar { flex-direction: column; align-items: stretch; }
|
.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 { 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); }
|
.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)
|
/* Тёмная тема: тултип «Скопировано» — заливка была var(--color-ink-900)
|
||||||
(тёмный индиго в светлой теме → стал бы светло-серым в тёмной, ломая
|
(тёмный индиго в светлой теме → стал бы светло-серым в тёмной, ломая
|
||||||
тёмный тултип поверх тёмной страницы); заменена на буквальный #333333
|
тёмный тултип поверх тёмной страницы); заменена на буквальный #333333
|
||||||
|
|||||||
@@ -417,6 +417,14 @@
|
|||||||
.modal-actions--split .modal-actions-group { display: flex; gap: var(--space-3); }
|
.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); }
|
.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