refactor(calendar): форма создания/редактирования конференции — в модальное окно
Раньше ConferenceFormCard всегда висела в сайдбаре двухколоночной сетки .layout-grid; теперь открывается в модалке (по образцу ConferenceOccurrenceDialog: modal-overlay/modal-panel, закрытие по клику на оверлей, Escape и возврат фокуса — новый общий хук useModalDismiss). .layout-grid стала одноколоночной — календарь занимает всю ширину.
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user