5 Commits

Author SHA1 Message Date
09a3c6c806 release: версия 0.0.4
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-26 23:53:44 +03:00
86f1facf61 chore: не коммитить локальные конфиги сессий (.claude/) 2026-07-26 23:52:31 +03:00
c897f40363 feat(calendar): информационное окно вхождения — статус, кнопка «Редактировать» для владельца
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
ConferenceOccurrenceDialog расширен под общий информационный диалог (клик по
чипу — и для чужих, и для своих конференций): статус-бейдж (общая логика
occurrenceBadge вынесена в lib/occurrenceStatus.ts, переиспользуется с
ConferenceCalendar), кнопка «Редактировать», открывающая модалку формы —
только когда передан ownedConference (текущий пользователь — организатор).
Плюс Escape и возврат фокуса (useModalDismiss).
2026-07-26 23:35:10 +03:00
66935b04f8 refactor(calendar): чип события — только название и карандашик, заголовок периода без «г.»
В renderEventContent убраны бейдж статуса и время (переехали в
ConferenceOccurrenceDialog) — остались название с обрезкой по многоточию
(min-width:0/overflow:hidden по цепочке флекс-контейнеров, чинит вылезание
текста за границы ячейки) и, у владельца конференции, карандашик
редактирования (myConferenceById/onEditClick, stopPropagation, чтобы не
срабатывал клик по чипу). Клик по чипу — всегда информационное окно, включая
свои конференции.

titleFormat для dayGridWeek/dayGridMonth построен на Intl.DateTimeFormat
вместо ruLocale-форматтера — тот всегда добавляет «г.» после года.
2026-07-26 23:34:49 +03:00
07e5aba9c9 refactor(calendar): форма создания/редактирования конференции — в модальное окно
Раньше ConferenceFormCard всегда висела в сайдбаре двухколоночной сетки
.layout-grid; теперь открывается в модалке (по образцу
ConferenceOccurrenceDialog: modal-overlay/modal-panel, закрытие по клику на
оверлей, Escape и возврат фокуса — новый общий хук useModalDismiss).
.layout-grid стала одноколоночной — календарь занимает всю ширину.
2026-07-26 23:33:52 +03:00
13 changed files with 349 additions and 127 deletions

View File

@@ -98,7 +98,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) --- # --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой # install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно. # установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.3 VIDCONF_VERSION=0.0.4
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного # --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг, # `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

4
.gitignore vendored
View File

@@ -50,3 +50,7 @@ backend/media/
# Рабочая папка Claude Code (промпты, агенты, заметки) — не в репозиторий # Рабочая папка Claude Code (промпты, агенты, заметки) — не в репозиторий
.forcc/ .forcc/
# Локальные конфиги инструментов сессий (launch.json dev-сервера и т.п.) —
# привязаны к конкретной машине, в репозиторий не идут.
.claude/

View File

@@ -3,6 +3,31 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/). проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.4] — 2026-07-26
Календарь конференций: перестроен в раскладку «календарь во всю ширину плюс
модальные окна» — карточки событий разгружены, подробности вынесены во
всплывающее окно.
### Изменения
- Форма создания и редактирования конференции больше не занимает постоянную
колонку справа, а открывается в модальном окне — по кнопке «Новая
конференция», по карандашику на карточке события или из информационного
окна. Добавлен общий хук `useModalDismiss` (закрытие по Escape и возврат
фокуса на элемент, из которого окно открыли).
- Блок календаря занимает всю ширину страницы.
- Карточка события в сетке содержит только название (обрезается многоточием и
больше не вылезает за границы ячейки) и — у организатора — кнопку
редактирования. Время, номер и статус переехали в информационное окно.
- Клик по карточке события всегда открывает информационное окно, в том числе
для своих конференций: редактирование стало отдельным действием.
- Информационное окно вхождения дополнено статус-бейджем и кнопкой
«Редактировать» для организатора.
### Исправления
- Подпись периода в недельном и месячном виде больше не содержит «г.»
(форматирование через `Intl.DateTimeFormat` вместо локали FullCalendar).
## [0.0.3] — 2026-07-26 ## [0.0.3] — 2026-07-26
Мобильная версия: адаптивная вёрстка для узких экранов на ключевых экранах Мобильная версия: адаптивная вёрстка для узких экранов на ключевых экранах
@@ -87,6 +112,7 @@
Первоначальная версия VidConf. Первоначальная версия VidConf.
[0.0.4]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.4
[0.0.3]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.3 [0.0.3]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.3
[0.0.2]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.2 [0.0.2]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.2
[0.0.1]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.1 [0.0.1]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.1

View File

@@ -1 +1 @@
0.0.3 0.0.4

View File

@@ -82,7 +82,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media} MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение # Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health. # из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.3} VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.4}
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки — # config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
# только backend/), поэтому plugins.yaml монтируется отдельно. # только backend/), поэтому plugins.yaml монтируется отдельно.
volumes: volumes:

View File

@@ -5,8 +5,8 @@ import type { EventClickArg, EventHoveringArg } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid' import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction' import interactionPlugin from '@fullcalendar/interaction'
import ruLocale from '@fullcalendar/core/locales/ru' import ruLocale from '@fullcalendar/core/locales/ru'
import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Lock, Play, Repeat } from 'lucide-react' import { ChevronLeft, ChevronRight, Lock, Pencil } from 'lucide-react'
import type { OccurrenceOut } from '@/api/conferences' import type { ConferenceOut, OccurrenceOut } from '@/api/conferences'
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard' import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
/* /*
@@ -35,6 +35,16 @@ const HIDDEN_DAYS_NONE: number[] = []
*/ */
const eventFocusCleanups = new WeakMap<HTMLElement, () => void>() const eventFocusCleanups = new WeakMap<HTMLElement, () => void>()
/**
* Актуальный колбэк редактирования из последнего рендера
* `ConferenceCalendar`. `renderEventContent` обязан оставаться одной и той же
* функцией на всё время жизни модуля (см. комментарий выше про
* resetOptions/бесконечный цикл), поэтому не может напрямую замкнуть пропс
* компонента — вместо этого читает его отсюда, а компонент синхронизирует
* переменную эффектом при каждом изменении пропса.
*/
let latestOnEditClick: (conference: ConferenceOut) => void = () => {}
export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth' export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth'
interface ConferenceCalendarProps { interface ConferenceCalendarProps {
@@ -42,74 +52,113 @@ interface ConferenceCalendarProps {
onViewChange: (view: CalendarViewMode) => void onViewChange: (view: CalendarViewMode) => void
occurrences: OccurrenceOut[] occurrences: OccurrenceOut[]
isLoading: boolean isLoading: boolean
/** Владельческие конференции текущего пользователя (`/conferences/my`, только свои — `is_owner`) — определяет, показывать ли карандашик на чипе. */
myConferenceById: Map<string, ConferenceOut>
/** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */ /** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */
onRangeChange: (fromIso: string, toIso: string) => void onRangeChange: (fromIso: string, toIso: string) => void
onOccurrenceClick: (occurrence: OccurrenceOut) => void onOccurrenceClick: (occurrence: OccurrenceOut) => void
onEditClick: (conference: ConferenceOut) => void
} }
type BadgeKind = 'scheduled' | 'pinned' | 'live' | 'ended' /**
* Не зависит от пропсов/состояния компонента — модульная константа-функция
function occurrenceBadge(occ: OccurrenceOut, start: Date | null, end: Date | null): { kind: BadgeKind; label: string } { * (см. комментарий выше про стабильность identity). В чипе — только название
const now = new Date() * (обрезается многоточием по цепочке min-width:0/overflow:hidden ниже по
if (start && end && now >= start && now <= end) return { kind: 'live', label: 'Идёт сейчас' } * дереву, см. calendar.css) и, у владельца, карандашик редактирования; время/
if (end && now > end) return { kind: 'ended', label: 'Завершена' } * статус/номер — только в информационном окне (`ConferenceOccurrenceDialog`).
if (occ.is_pinned) return { kind: 'pinned', label: 'Закреплена' } */
return { kind: 'scheduled', label: 'Запланирована' }
}
const BADGE_ICON: Record<BadgeKind, typeof Clock> = {
scheduled: Clock,
pinned: Repeat,
live: Play,
ended: CheckCircle2,
}
/** Не зависит от пропсов/состояния компонента — модульная константа-функция (см. комментарий выше про стабильность identity). */
function renderEventContent(arg: EventContentArg) { function renderEventContent(arg: EventContentArg) {
const occ = arg.event.extendedProps.occurrence as OccurrenceOut const occ = arg.event.extendedProps.occurrence as OccurrenceOut
const start = arg.event.start const ownedConference = arg.event.extendedProps.ownedConference as ConferenceOut | undefined
const end = arg.event.end
const badge = occurrenceBadge(occ, start, end)
const Icon = BADGE_ICON[badge.kind]
const timeLabel =
start && end
? `${start.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}${end.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`
: ''
return ( return (
<div> <div className="chip-row">
<span className={`conf-badge ${badge.kind}`}>
<Icon style={{ width: 11, height: 11 }} aria-hidden="true" />
{badge.label}
</span>
<span className="chip-time">{timeLabel}</span>
<span className="chip-title"> <span className="chip-title">
{occ.is_closed && <Lock className="chip-lock" aria-hidden="true" />} {occ.is_closed && <Lock className="chip-lock" aria-hidden="true" />}
{arg.event.title} <span className="chip-title-text">{arg.event.title}</span>
</span> </span>
{ownedConference && (
<button
type="button"
className="chip-edit"
aria-label="Редактировать конференцию"
onClick={(e) => {
// Иначе клик долетит и до обработчика чипа (eventClick) — тот
// открыл бы информационное окно вместо формы редактирования.
e.stopPropagation()
latestOnEditClick(ownedConference)
}}
>
<Pencil aria-hidden="true" />
</button>
)}
</div> </div>
) )
} }
/** «г.» — сноска Intl.DateTimeFormat('ru-RU', ...) после года при формате year+month(+day); в подписи периода не нужна. */
const YEAR_SUFFIX_RE = /\s*г\.(?=\s|$)/g
/**
* Форма аргумента, который FullCalendar передаёт функции-`titleFormat`
* (`VerboseFormattingArg` из @fullcalendar/core/internal-common — не
* реэкспортируется из публичного `@fullcalendar/core`, поэтому типизируем
* структурно сами, только нужные поля).
*/
interface TitleFormatArg {
start: { marker: Date }
end?: { marker: Date }
}
/**
* Строит titleFormat-функцию FullCalendar на Intl.DateTimeFormat вместо
* ruLocale-зависимого форматтера — тот всегда добавляет «г.» после года.
* FullCalendar передаёт границы диапазона уже с учётом exclusivity (конец
* периода минус 1мс, см. `dateEnv.formatRange` в @fullcalendar/core) — этого
* достаточно, чтобы Intl.formatRange сам корректно схлопнул одинаковые
* месяц/год и красиво отформатировал переход через месяц/год.
*/
function buildTitleFormatter(options: Intl.DateTimeFormatOptions) {
const formatter = new Intl.DateTimeFormat('ru-RU', { ...options, timeZone: 'UTC' })
return (arg: TitleFormatArg): string => {
const raw = arg.end ? formatter.formatRange(arg.start.marker, arg.end.marker) : formatter.format(arg.start.marker)
return raw.replace(YEAR_SUFFIX_RE, '')
}
}
const CALENDAR_VIEWS = {
dayGridWeek: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long', day: 'numeric' }) },
dayGridMonth: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long' }) },
}
/** /**
* Обёртка над FullCalendar (см. design/mockups/calendar.html): недельная * Обёртка над FullCalendar (см. design/mockups/calendar.html): недельная
* (только будни, 5 колонок) / месячная сетка вхождений конференций, свой * (только будни, 5 колонок) / месячная сетка вхождений конференций, свой
* тулбар (вид, навигация по датам) вместо встроенного headerToolbar. * тулбар (вид, навигация по датам) вместо встроенного headerToolbar.
* События рендерятся как «чипы» (`.conf-chip`) со статус-бейджем §4.6 * События рендерятся как «чипы» (`.conf-chip`) — только название (+карандашик
* DESIGN_SYSTEM.md — вместо позиционирования по времени (timeGrid), т.к. * у владельца), статус/время/номер — в информационном окне по клику
* менеджер конференций показывает список вхождений по дням, не занятость * (`ConferenceOccurrenceDialog`) — вместо позиционирования по времени
* переговорной по часам (переговорных комнат в модели нет). * (timeGrid), т.к. менеджер конференций показывает список вхождений по дням,
* не занятость переговорной по часам (переговорных комнат в модели нет).
*/ */
export function ConferenceCalendar({ export function ConferenceCalendar({
view, view,
onViewChange, onViewChange,
occurrences, occurrences,
isLoading, isLoading,
myConferenceById,
onRangeChange, onRangeChange,
onOccurrenceClick, onOccurrenceClick,
onEditClick,
}: ConferenceCalendarProps) { }: ConferenceCalendarProps) {
const calendarRef = useRef<FullCalendar | null>(null) const calendarRef = useRef<FullCalendar | null>(null)
const titleRef = useRef<HTMLSpanElement | null>(null) const titleRef = useRef<HTMLSpanElement | null>(null)
// См. комментарий у `latestOnEditClick` в начале файла — синхронизируем
// переменную вместо того, чтобы замыкать пропс напрямую в renderEventContent.
useEffect(() => {
latestOnEditClick = onEditClick
}, [onEditClick])
// Ховер-карточка конференции — состояние держим внутри // Ховер-карточка конференции — состояние держим внутри
// компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже // компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже
// оставались стабильными по identity между рендерами (см. комментарий в // оставались стабильными по identity между рендерами (см. комментарий в
@@ -159,20 +208,24 @@ export function ConferenceCalendar({
calendarRef.current?.getApi().today() calendarRef.current?.getApi().today()
} }
// Мемоизировано по `occurrences` — иначе на каждый рендер создавался бы // Мемоизировано по `occurrences`/`myConferenceById` — иначе на каждый рендер
// новый массив-`events` той же длины/содержимого, и FullCalendar считал бы // создавался бы новый массив-`events` той же длины/содержимого, и
// его «изменившейся опцией» (см. комментарий у модульных констант выше). // FullCalendar считал бы его «изменившейся опцией» (см. комментарий у
// модульных констант выше).
const events: EventInput[] = useMemo( const events: EventInput[] = useMemo(
() => () =>
occurrences.map((occ) => ({ occurrences.map((occ) => {
id: `${occ.conference_id}:${occ.starts_at}`, const owned = myConferenceById.get(occ.conference_id)
title: occ.title ?? 'Конференция без названия', return {
start: occ.starts_at, id: `${occ.conference_id}:${occ.starts_at}`,
end: occ.ends_at, title: occ.title ?? 'Конференция без названия',
classNames: ['conf-chip'], start: occ.starts_at,
extendedProps: { occurrence: occ }, end: occ.ends_at,
})), classNames: ['conf-chip'],
[occurrences], extendedProps: { occurrence: occ, ownedConference: owned?.is_owner ? owned : undefined },
}
}),
[occurrences, myConferenceById],
) )
const handleEventClick = useCallback( const handleEventClick = useCallback(
@@ -228,6 +281,7 @@ export function ConferenceCalendar({
hiddenDays={view === 'dayGridWeek' ? HIDDEN_DAYS_BUSINESS_WEEK : HIDDEN_DAYS_NONE} hiddenDays={view === 'dayGridWeek' ? HIDDEN_DAYS_BUSINESS_WEEK : HIDDEN_DAYS_NONE}
height="auto" height="auto"
dayMaxEvents={false} dayMaxEvents={false}
views={CALENDAR_VIEWS}
events={events} events={events}
eventContent={renderEventContent} eventContent={renderEventContent}
eventClick={handleEventClick} eventClick={handleEventClick}

View File

@@ -1,10 +1,15 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Calendar, Lock, Play, Repeat, X } from 'lucide-react' import { Calendar, Hash, Lock, Pencil, Play, Repeat, X } from 'lucide-react'
import type { OccurrenceOut } from '@/api/conferences' import type { ConferenceOut, OccurrenceOut } from '@/api/conferences'
import { occurrenceBadge, OCCURRENCE_BADGE_ICON } from '@/lib/occurrenceStatus'
import { useModalDismiss } from '@/hooks/useModalDismiss'
interface ConferenceOccurrenceDialogProps { interface ConferenceOccurrenceDialogProps {
occurrence: OccurrenceOut occurrence: OccurrenceOut
/** Присутствует только когда текущий пользователь — организатор этого вхождения (`is_owner`) — показывает кнопку «Редактировать». */
ownedConference?: ConferenceOut
onClose: () => void onClose: () => void
onEdit?: (conference: ConferenceOut) => void
} }
function formatRange(startsAt: string, endsAt: string): string { function formatRange(startsAt: string, endsAt: string): string {
@@ -18,14 +23,19 @@ function formatRange(startsAt: string, endsAt: string): string {
/** /**
* Быстрый просмотр вхождения конференции по клику на чип в календаре — для * Быстрый просмотр вхождения конференции по клику на чип в календаре — для
* конференций, которые пользователь не организует (не владелец — * всех конференций, включая свои: редактирование теперь отдельное действие
* `is_owner`): свои конференции CalendarPage открывает сразу в форме * (карандашик на чипе или кнопка «Редактировать» здесь, у владельца), сам
* редактирования (`ConferenceFormCard`). Здесь — минимум: когда, статус, вход * клик по чипу всегда открывает это окно. Показывает статус, время, номер и,
* и ссылка на управление; подробности состава участников/организатора — в * для владельца, вход в редактирование; подробности состава
* ховер-карточке при наведении/фокусе на чип (`ConferenceHoverCard`). * участников/организатора — в ховер-карточке при наведении/фокусе на чип
* (`ConferenceHoverCard`).
*/ */
export function ConferenceOccurrenceDialog({ occurrence, onClose }: ConferenceOccurrenceDialogProps) { export function ConferenceOccurrenceDialog({ occurrence, ownedConference, onClose, onEdit }: ConferenceOccurrenceDialogProps) {
const navigate = useNavigate() const navigate = useNavigate()
useModalDismiss(onClose)
const badge = occurrenceBadge(occurrence, new Date(occurrence.starts_at), new Date(occurrence.ends_at))
const BadgeIcon = OCCURRENCE_BADGE_ICON[badge.kind]
return ( return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="occurrence-dialog-title" onClick={onClose}> <div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="occurrence-dialog-title" onClick={onClose}>
@@ -40,13 +50,18 @@ export function ConferenceOccurrenceDialog({ occurrence, onClose }: ConferenceOc
</button> </button>
</div> </div>
<span className={`conf-badge ${badge.kind}`} style={{ marginBottom: 'var(--space-4)' }}>
<BadgeIcon style={{ width: 11, height: 11 }} aria-hidden="true" />
{badge.label}
</span>
<div className="detail-row"> <div className="detail-row">
<Calendar style={{ width: 18, height: 18 }} aria-hidden="true" /> <Calendar style={{ width: 18, height: 18 }} aria-hidden="true" />
<span className="label">Когда</span> <span className="label">Когда</span>
<span>{formatRange(occurrence.starts_at, occurrence.ends_at)}</span> <span>{formatRange(occurrence.starts_at, occurrence.ends_at)}</span>
</div> </div>
<div className="detail-row"> <div className="detail-row">
<Repeat style={{ width: 18, height: 18 }} aria-hidden="true" /> <Hash style={{ width: 18, height: 18 }} aria-hidden="true" />
<span className="label">Номер</span> <span className="label">Номер</span>
<span>{occurrence.number}</span> <span>{occurrence.number}</span>
</div> </div>
@@ -63,10 +78,18 @@ export function ConferenceOccurrenceDialog({ occurrence, onClose }: ConferenceOc
<Play style={{ width: 16, height: 16 }} aria-hidden="true" /> <Play style={{ width: 16, height: 16 }} aria-hidden="true" />
Войти Войти
</button> </button>
{ownedConference && onEdit && (
<button type="button" className="btn btn-secondary" onClick={() => onEdit(ownedConference)}>
<Pencil style={{ width: 16, height: 16 }} aria-hidden="true" />
Редактировать
</button>
)}
</div> </div>
<p className="field-hint" style={{ textAlign: 'center', marginTop: 'var(--space-3)' }}> {!ownedConference && (
Управление и редактирование в <Link to="/my-conferences">«Моих конференциях»</Link> <p className="field-hint" style={{ textAlign: 'center', marginTop: 'var(--space-3)' }}>
</p> Управление и редактирование в <Link to="/my-conferences">«Моих конференциях»</Link>
</p>
)}
</div> </div>
</div> </div>
) )

View 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()
}
}, [])
}

View File

@@ -0,0 +1,24 @@
import { CheckCircle2, Clock, Play, Repeat } from 'lucide-react'
import type { OccurrenceOut } from '@/api/conferences'
export type OccurrenceBadgeKind = 'scheduled' | 'pinned' | 'live' | 'ended'
/** Общая логика статус-бейджа вхождения — используется чипом календаря и информационным окном (`ConferenceOccurrenceDialog`). */
export function occurrenceBadge(
occ: OccurrenceOut,
start: Date | null,
end: Date | null,
): { kind: OccurrenceBadgeKind; label: string } {
const now = new Date()
if (start && end && now >= start && now <= end) return { kind: 'live', label: 'Идёт сейчас' }
if (end && now > end) return { kind: 'ended', label: 'Завершена' }
if (occ.is_pinned) return { kind: 'pinned', label: 'Закреплена' }
return { kind: 'scheduled', label: 'Запланирована' }
}
export const OCCURRENCE_BADGE_ICON: Record<OccurrenceBadgeKind, typeof Clock> = {
scheduled: Clock,
pinned: Repeat,
live: Play,
ended: CheckCircle2,
}

View File

@@ -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>
)
}

View File

@@ -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 {
@@ -67,12 +70,15 @@
* Событие-вхождение конференции — карточка-«чип» в духе .conf-chip мокапа. * Событие-вхождение конференции — карточка-«чип» в духе .conf-chip мокапа.
* FullCalendar оборачивает событие в .fc-daygrid-event (наследует классы из * FullCalendar оборачивает событие в .fc-daygrid-event (наследует классы из
* `classNames`) — переопределяем его внешний вид под чип целиком, без * `classNames`) — переопределяем его внешний вид под чип целиком, без
* отдельного контейнера. * отдельного контейнера. Содержимое — только название (+карандашик у
* владельца, время/статус/номер — в ConferenceOccurrenceDialog), поэтому чип —
* одна строка (flex row), а не колонка бейдж/время/название, как раньше.
*/ */
.vc-calendar .fc-daygrid-event.conf-chip { .vc-calendar .fc-daygrid-event.conf-chip {
display: flex !important; display: flex !important;
flex-direction: column; align-items: center;
gap: 4px; gap: 8px;
min-width: 0;
background: var(--color-surface) !important; background: var(--color-surface) !important;
border: 1px solid var(--color-border) !important; border: 1px solid var(--color-border) !important;
border-radius: var(--radius-md) !important; border-radius: var(--radius-md) !important;
@@ -85,10 +91,34 @@
.vc-calendar .fc-daygrid-event.conf-chip .fc-event-time, .vc-calendar .fc-daygrid-event.conf-chip .fc-event-time,
.vc-calendar .fc-daygrid-event.conf-chip .fc-event-title { display: none; } /* заменено собственным eventContent */ .vc-calendar .fc-daygrid-event.conf-chip .fc-event-title { display: none; } /* заменено собственным eventContent */
.chip-time { display: block; font: var(--text-caption); color: var(--color-ink-500); text-transform: none; letter-spacing: normal; } /* Цепочка min-width:0 + overflow:hidden + text-overflow:ellipsis — без неё
.chip-title { display: flex; align-items: center; gap: 6px; font: var(--text-body); font-weight: 700; color: var(--color-ink-900); } длинное название распирает чип и вылезает за границы ячейки (в узкой
недельной колонке — особенно). `.fc-daygrid-event-harness` — прямой
flex-item `.fc-daygrid-day-events` (флекс-колонка) выше по файлу: по
умолчанию у флекс-айтема min-width:auto (= min-content), из-за чего он не
сжимается уже ширины своего содержимого — тот самый grid/flex-blowout. */
.vc-calendar .fc-daygrid-event-harness { min-width: 0; }
.chip-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
.chip-title { display: flex; align-items: center; gap: 6px; font: var(--text-body); font-weight: 700; color: var(--color-ink-900); min-width: 0; flex: 1; }
.chip-title-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.chip-lock { width: 13px; height: 13px; flex-shrink: 0; } .chip-lock { width: 13px; height: 13px; flex-shrink: 0; }
.chip-edit {
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 50%;
border: none;
background: transparent;
color: var(--color-ink-500);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.chip-edit svg { width: 13px; height: 13px; }
.chip-edit:hover { background: var(--color-bg-alt); color: var(--color-ink-900); }
.conf-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 9px; border-radius: var(--radius-full); font: var(--text-caption); font-size: 11px; width: fit-content; } .conf-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 9px; border-radius: var(--radius-full); font: var(--text-caption); font-size: 11px; width: fit-content; }
.conf-badge.scheduled { background: var(--color-warning-bg); color: var(--color-warning); } .conf-badge.scheduled { background: var(--color-warning-bg); color: var(--color-warning); }
.conf-badge.pinned { background: var(--color-bg-alt); color: var(--color-ink-700); border: 1px solid var(--color-border-strong); } .conf-badge.pinned { background: var(--color-bg-alt); color: var(--color-ink-700); border: 1px solid var(--color-border-strong); }
@@ -242,17 +272,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; }

View File

@@ -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

View File

@@ -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; }
/* Узкий экран: «Удалить» + «Отмена» + «Сохранить» в один ряд не помещаются /* Узкий экран: «Удалить» + «Отмена» + «Сохранить» в один ряд не помещаются
(в сумме шире содержимого модалки на телефоне) — переносим «Удалить» на (в сумме шире содержимого модалки на телефоне) — переносим «Удалить» на
свою строку сверху. */ свою строку сверху. */