Compare commits
12 Commits
v0.0.2
...
c897f40363
| Author | SHA1 | Date | |
|---|---|---|---|
| c897f40363 | |||
| 66935b04f8 | |||
| 07e5aba9c9 | |||
| 904db83450 | |||
| 525d6dde1b | |||
| dbda749756 | |||
| 3c36a45d57 | |||
| a04ae3152f | |||
| 6813464e92 | |||
| ac9832c5a1 | |||
| 84e2ab8756 | |||
| 372a7a9378 |
@@ -98,7 +98,7 @@ SMTP_TIMEOUT_S=30
|
|||||||
# --- Версия инстанса (релиз v0.0.1) ---
|
# --- Версия инстанса (релиз v0.0.1) ---
|
||||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||||
# установке/обновлении — руками менять не нужно.
|
# установке/обновлении — руками менять не нужно.
|
||||||
VIDCONF_VERSION=0.0.2
|
VIDCONF_VERSION=0.0.3
|
||||||
|
|
||||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||||
|
|||||||
29
CHANGELOG.md
29
CHANGELOG.md
@@ -3,6 +3,34 @@
|
|||||||
Формат основан на [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.3] — 2026-07-26
|
||||||
|
|
||||||
|
Мобильная версия: адаптивная вёрстка для узких экранов на ключевых экранах
|
||||||
|
сервиса, найденная и поправленная по итогам ручного прохода по мобильному
|
||||||
|
интерфейсу.
|
||||||
|
|
||||||
|
### Исправления
|
||||||
|
- Вход/регистрация (`AuthLayout`): заголовок «Ваша инфраструктура» вылезал за
|
||||||
|
край брендовой панели на узких экранах — кегль теперь адаптивно уменьшается
|
||||||
|
(`clamp()`) вместо обрезки.
|
||||||
|
- Комната конференции: топбар и тулбар не помещались на мобильной ширине
|
||||||
|
(тулбар с 8 кнопками физически шире экрана, крайние кнопки были обрезаны без
|
||||||
|
возможности до них докрутить). На мобильном тулбар сокращён до
|
||||||
|
микрофона/камеры/устройств/чата/выхода (демонстрация экрана,
|
||||||
|
полноэкранный режим и мини-окно скрыты — редко нужны с телефона), в топбаре
|
||||||
|
скрыты название конференции и строка статуса, а чип «№ … / Пригласить»
|
||||||
|
оставлен — с телефона он нужнее.
|
||||||
|
- Админка: заголовок и 4 вкладки раздела не помещались в ширину (вкладки
|
||||||
|
переносятся в 2 ряда, поясняющий текст под заголовком скрыт на мобильном);
|
||||||
|
таблицы «Конференции» и «Пользователи» обрезались вбок — на мобильном в них
|
||||||
|
остаются только ключевые колонки, название конференции стало кнопкой,
|
||||||
|
открывающей то же окно редактирования, что и раньше (туда же добавлена
|
||||||
|
кнопка удаления — на мобильном столбец действий скрыт).
|
||||||
|
- Подключение к конференции (`/join`): кнопка «Ввести другую ссылку или
|
||||||
|
номер» на экране завершённой конференции вылезала за карточку на узких
|
||||||
|
экранах (единственная кнопка на этом флоу без формы-обёртки, задающей ей
|
||||||
|
ширину, а `.btn` держит `white-space: nowrap`).
|
||||||
|
|
||||||
## [0.0.2] — 2026-07-26
|
## [0.0.2] — 2026-07-26
|
||||||
|
|
||||||
Релиз воспроизводимого боевого развёртывания: конфигурация деплоя перенесена в
|
Релиз воспроизводимого боевого развёртывания: конфигурация деплоя перенесена в
|
||||||
@@ -59,5 +87,6 @@
|
|||||||
|
|
||||||
Первоначальная версия VidConf.
|
Первоначальная версия VidConf.
|
||||||
|
|
||||||
|
[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
|
||||||
|
|||||||
@@ -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.2}
|
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.3}
|
||||||
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
|
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
|
||||||
# только backend/), поэтому plugins.yaml монтируется отдельно.
|
# только backend/), поэтому plugins.yaml монтируется отдельно.
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -50,7 +50,18 @@ function StatusBadge({ conference }: { conference: AdminConferenceOut }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Форма быстрого редактирования конференции из админки (title/время/пароль/рассылка). */
|
/** Форма быстрого редактирования конференции из админки (title/время/пароль/рассылка). */
|
||||||
function EditConferenceModal({ conference, onClose }: { conference: AdminConferenceOut; onClose: () => void }) {
|
function EditConferenceModal({
|
||||||
|
conference,
|
||||||
|
onClose,
|
||||||
|
onDelete,
|
||||||
|
deleting,
|
||||||
|
}: {
|
||||||
|
conference: AdminConferenceOut
|
||||||
|
onClose: () => void
|
||||||
|
/** Удаление конференции — та же мутация, что и у корзины в строке таблицы (см. AdminConferencesTab). */
|
||||||
|
onDelete: () => void
|
||||||
|
deleting: boolean
|
||||||
|
}) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const initialSlot = conference.scheduled_at ? toLocalDateTimeParts(conference.scheduled_at) : { date: '', time: '' }
|
const initialSlot = conference.scheduled_at ? toLocalDateTimeParts(conference.scheduled_at) : { date: '', time: '' }
|
||||||
@@ -64,6 +75,7 @@ function EditConferenceModal({ conference, onClose }: { conference: AdminConfere
|
|||||||
const [summaryRecipients, setSummaryRecipients] = useState<'' | SummaryRecipientsMode>(
|
const [summaryRecipients, setSummaryRecipients] = useState<'' | SummaryRecipientsMode>(
|
||||||
conference.summary_recipients ?? '',
|
conference.summary_recipients ?? '',
|
||||||
)
|
)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (payload: AdminConferenceUpdatePayload) => updateAdminConference(conference.id, payload),
|
mutationFn: (payload: AdminConferenceUpdatePayload) => updateAdminConference(conference.id, payload),
|
||||||
@@ -179,7 +191,24 @@ function EditConferenceModal({ conference, onClose }: { conference: AdminConfere
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-actions">
|
<div className="modal-actions modal-actions--split">
|
||||||
|
{confirmDelete ? (
|
||||||
|
<div className="row-actions">
|
||||||
|
<span className="field-hint">Удалить без возможности восстановления?</span>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setConfirmDelete(false)}>
|
||||||
|
Нет
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger" disabled={deleting} onClick={onDelete}>
|
||||||
|
{deleting ? 'Удаляем…' : 'Да, удалить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn btn-danger" onClick={() => setConfirmDelete(true)}>
|
||||||
|
<Trash2 style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="modal-actions-group">
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
@@ -187,6 +216,7 @@ function EditConferenceModal({ conference, onClose }: { conference: AdminConfere
|
|||||||
{mutation.isPending ? 'Сохраняем…' : 'Сохранить'}
|
{mutation.isPending ? 'Сохраняем…' : 'Сохранить'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,6 +323,9 @@ export function AdminConferencesTab() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'conferences'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'conferences'] })
|
||||||
toast.show('Конференция удалена', 'success')
|
toast.show('Конференция удалена', 'success')
|
||||||
setConfirmingDeleteId(null)
|
setConfirmingDeleteId(null)
|
||||||
|
// Удаление могло прийти и из модалки редактирования (кнопка «Удалить»
|
||||||
|
// там дёргает эту же мутацию) — закрываем её тоже, если открыта.
|
||||||
|
setEditingConference(null)
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
if (err instanceof ApiError && err.status === 409) {
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
@@ -340,7 +373,7 @@ export function AdminConferencesTab() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="table-card">
|
<div className="table-card conferences-table">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -363,11 +396,16 @@ export function AdminConferencesTab() {
|
|||||||
{items.map((conf) => (
|
{items.map((conf) => (
|
||||||
<tr key={conf.id}>
|
<tr key={conf.id}>
|
||||||
<td>
|
<td>
|
||||||
|
{/* На мобильном это единственный вход в редактирование строки (см. .conferences-table
|
||||||
|
в admin.css — остальные колонки там скрыты), на десктопе — то же самое, что кнопка
|
||||||
|
«Редактировать» правее, просто более крупная область клика. */}
|
||||||
|
<button type="button" className="conf-cell-name" onClick={() => setEditingConference(conf)}>
|
||||||
{conf.is_closed && <Lock style={{ width: 14, height: 14, marginRight: 6, verticalAlign: '-2px' }} aria-hidden="true" />}
|
{conf.is_closed && <Lock style={{ width: 14, height: 14, marginRight: 6, verticalAlign: '-2px' }} aria-hidden="true" />}
|
||||||
{conf.title ?? 'Без названия'}
|
{conf.title ?? 'Без названия'}
|
||||||
<span style={{ display: 'block', font: 'var(--text-caption)', color: 'var(--color-ink-500)' }}>
|
<span style={{ display: 'block', font: 'var(--text-caption)', color: 'var(--color-ink-500)' }}>
|
||||||
№ {conf.number}
|
№ {conf.number}
|
||||||
</span>
|
</span>
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{conf.owner_name ?? '—'}
|
{conf.owner_name ?? '—'}
|
||||||
@@ -443,7 +481,14 @@ export function AdminConferencesTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingConference && <EditConferenceModal conference={editingConference} onClose={() => setEditingConference(null)} />}
|
{editingConference && (
|
||||||
|
<EditConferenceModal
|
||||||
|
conference={editingConference}
|
||||||
|
onClose={() => setEditingConference(null)}
|
||||||
|
onDelete={() => deleteMutation.mutate(editingConference.id)}
|
||||||
|
deleting={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{invitingConference && <InviteModal conference={invitingConference} onClose={() => setInvitingConference(null)} />}
|
{invitingConference && <InviteModal conference={invitingConference} onClose={() => setInvitingConference(null)} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function AdminUsersTab() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="table-card">
|
<div className="table-card users-table">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
const owned = myConferenceById.get(occ.conference_id)
|
||||||
|
return {
|
||||||
id: `${occ.conference_id}:${occ.starts_at}`,
|
id: `${occ.conference_id}:${occ.starts_at}`,
|
||||||
title: occ.title ?? 'Конференция без названия',
|
title: occ.title ?? 'Конференция без названия',
|
||||||
start: occ.starts_at,
|
start: occ.starts_at,
|
||||||
end: occ.ends_at,
|
end: occ.ends_at,
|
||||||
classNames: ['conf-chip'],
|
classNames: ['conf-chip'],
|
||||||
extendedProps: { occurrence: occ },
|
extendedProps: { occurrence: occ, ownedConference: owned?.is_owner ? owned : undefined },
|
||||||
})),
|
}
|
||||||
[occurrences],
|
}),
|
||||||
|
[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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{!ownedConference && (
|
||||||
<p className="field-hint" style={{ textAlign: 'center', marginTop: 'var(--space-3)' }}>
|
<p className="field-hint" style={{ textAlign: 'center', marginTop: 'var(--space-3)' }}>
|
||||||
Управление и редактирование — в <Link to="/my-conferences">«Моих конференциях»</Link>
|
Управление и редактирование — в <Link to="/my-conferences">«Моих конференциях»</Link>
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export function RoomToolbar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
{...screenShare.buttonProps}
|
{...screenShare.buttonProps}
|
||||||
className={`tb-btn${screenShare.enabled ? ' is-sharing' : ''}`}
|
className={`tb-btn tb-btn--screenshare${screenShare.enabled ? ' is-sharing' : ''}`}
|
||||||
aria-label={screenShare.enabled ? 'Остановить демонстрацию экрана' : 'Демонстрировать экран'}
|
aria-label={screenShare.enabled ? 'Остановить демонстрацию экрана' : 'Демонстрировать экран'}
|
||||||
>
|
>
|
||||||
<span className="icon-shell">
|
<span className="icon-shell">
|
||||||
@@ -155,7 +155,7 @@ export function RoomToolbar({
|
|||||||
{fullscreenSupported && (
|
{fullscreenSupported && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`tb-btn${fullscreenActive ? ' is-panel-open' : ''}`}
|
className={`tb-btn tb-btn--fullscreen${fullscreenActive ? ' is-panel-open' : ''}`}
|
||||||
aria-label={fullscreenActive ? 'Выйти из полноэкранного режима' : 'Развернуть на весь экран'}
|
aria-label={fullscreenActive ? 'Выйти из полноэкранного режима' : 'Развернуть на весь экран'}
|
||||||
aria-pressed={fullscreenActive}
|
aria-pressed={fullscreenActive}
|
||||||
onClick={onToggleFullscreen}
|
onClick={onToggleFullscreen}
|
||||||
@@ -174,7 +174,7 @@ export function RoomToolbar({
|
|||||||
{pipSupported && (
|
{pipSupported && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`tb-btn${pipActive ? ' is-panel-open' : ''}`}
|
className={`tb-btn tb-btn--pip${pipActive ? ' is-panel-open' : ''}`}
|
||||||
aria-label={pipActive ? 'Свернуть мини-окно' : 'Открыть мини-окно'}
|
aria-label={pipActive ? 'Свернуть мини-окно' : 'Открыть мини-окно'}
|
||||||
aria-pressed={pipActive}
|
aria-pressed={pipActive}
|
||||||
onClick={onTogglePiP}
|
onClick={onTogglePiP}
|
||||||
|
|||||||
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()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
24
frontend/src/lib/occurrenceStatus.ts
Normal file
24
frontend/src/lib/occurrenceStatus.ts
Normal 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,
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,33 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
.admin-page .section-tabs { display: flex; gap: var(--space-2); margin-bottom: var(--space-5); border-bottom: 1px solid var(--color-border); }
|
.admin-page .section-tabs { display: flex; gap: var(--space-2); margin-bottom: var(--space-5); border-bottom: 1px solid var(--color-border); }
|
||||||
|
|
||||||
|
/* Мобильная ширина: 4 вкладки не помещаются в один ряд (см. аудит) —
|
||||||
|
переносим в 2 ряда по 2, а необязательный поясняющий текст под заголовком
|
||||||
|
прячем, чтобы не съедал экран (сам заголовок тянет .page-head h1 из
|
||||||
|
lobby.css). */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.admin-page .page-head p { display: none; }
|
||||||
|
.admin-page .section-tabs { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Таблица «Пользователи» — на мобильном оставляем только колонку
|
||||||
|
«Пользователь»; роль/команда/блокировка редактируются через карточку
|
||||||
|
профиля (открывается кликом по имени, см. AdminUserProfileDialog). */
|
||||||
|
.admin-page .users-table th:not(:first-child),
|
||||||
|
.admin-page .users-table td:not(:first-child) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Таблица «Конференции» — оставляем «Конференция» и «Владелец», статус/
|
||||||
|
тип/начало/действия скрыты; клик по названию (.conf-cell-name) открывает
|
||||||
|
то же редактирование, что раньше было доступно только через карандаш в
|
||||||
|
скрытой колонке действий — там же теперь есть и удаление (см. кнопку
|
||||||
|
«Удалить» в EditConferenceModal). */
|
||||||
|
.admin-page .conferences-table th:not(:nth-child(-n+2)),
|
||||||
|
.admin-page .conferences-table td:not(:nth-child(-n+2)) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
.admin-page .section-tab { padding: 10px 4px; margin-right: var(--space-6); background: none; border: none; border-bottom: 2px solid transparent; font: var(--text-body-lg); font-weight: 600; color: var(--color-ink-500); cursor: pointer; }
|
.admin-page .section-tab { padding: 10px 4px; margin-right: var(--space-6); background: none; border: none; border-bottom: 2px solid transparent; font: var(--text-body-lg); font-weight: 600; color: var(--color-ink-500); cursor: pointer; }
|
||||||
.admin-page .section-tab:hover { color: var(--color-ink-900); }
|
.admin-page .section-tab:hover { color: var(--color-ink-900); }
|
||||||
.admin-page .section-tab.is-active { color: var(--color-ink-700); border-color: var(--color-accent-active); }
|
.admin-page .section-tab.is-active { color: var(--color-ink-700); border-color: var(--color-accent-active); }
|
||||||
@@ -64,6 +91,14 @@
|
|||||||
.admin-page .user-cell-name { background: none; border: none; padding: 0; font: inherit; color: inherit; cursor: pointer; text-align: left; }
|
.admin-page .user-cell-name { background: none; border: none; padding: 0; font: inherit; color: inherit; cursor: pointer; text-align: left; }
|
||||||
.admin-page .user-cell-name:hover { text-decoration: underline; color: var(--color-ink-700); }
|
.admin-page .user-cell-name:hover { text-decoration: underline; color: var(--color-ink-700); }
|
||||||
|
|
||||||
|
/* Название конференции с №, оформленное кнопкой — открывает то же
|
||||||
|
редактирование, что и карандаш в row-actions правее (см.
|
||||||
|
AdminConferencesTab). На мобильном (.conferences-table ниже) это
|
||||||
|
единственный вход в редактирование строки, на десктопе — просто более
|
||||||
|
крупная область клика поверх уже существующей иконки. */
|
||||||
|
.admin-page .conf-cell-name { background: none; border: none; padding: 0; margin: 0; font: inherit; color: inherit; cursor: pointer; text-align: left; display: block; width: 100%; }
|
||||||
|
.admin-page .conf-cell-name:hover { color: var(--color-ink-700); }
|
||||||
|
|
||||||
.admin-page select { font: var(--text-body); color: var(--color-ink-900); padding: 6px 10px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: var(--color-surface); font-family: var(--font-body); }
|
.admin-page select { font: var(--text-body); color: var(--color-ink-900); padding: 6px 10px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: var(--color-surface); font-family: var(--font-body); }
|
||||||
.admin-page select:disabled { opacity: .5; cursor: not-allowed; }
|
.admin-page select:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
.admin-page select:focus { outline: none; border-color: var(--color-focus-ring); }
|
.admin-page select:focus { outline: none; border-color: var(--color-focus-ring); }
|
||||||
|
|||||||
@@ -244,7 +244,10 @@
|
|||||||
right: -90px;
|
right: -90px;
|
||||||
bottom: -90px;
|
bottom: -90px;
|
||||||
}
|
}
|
||||||
.brand-headline { max-width: none; }
|
/* clamp() — на узких экранах слово «инфраструктура» иначе вылезает за
|
||||||
|
край брендовой панели (см. .brand-panel padding ниже и её overflow:hidden,
|
||||||
|
обрезающий текст без переноса вместо уменьшения кегля). */
|
||||||
|
.brand-headline { max-width: none; font-size: clamp(22px, 6.2vw, 34px); }
|
||||||
.brand-sub { max-width: none; }
|
.brand-sub { max-width: none; }
|
||||||
.brand-stats { flex-wrap: wrap; }
|
.brand-stats { flex-wrap: wrap; }
|
||||||
.stat-glass { flex: 1 1 140px; min-width: 0; }
|
.stat-glass { flex: 1 1 140px; min-width: 0; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -55,6 +55,14 @@
|
|||||||
.field.has-error input { border-color: var(--color-danger); box-shadow: 0 0 0 3px rgba(199, 54, 79, 0.1); }
|
.field.has-error input { border-color: var(--color-danger); box-shadow: 0 0 0 3px rgba(199, 54, 79, 0.1); }
|
||||||
.error-msg { display: flex; align-items: center; gap: 6px; margin-top: var(--space-2); font: var(--text-body); color: var(--color-danger); }
|
.error-msg { display: flex; align-items: center; gap: 6px; margin-top: var(--space-2); font: var(--text-body); color: var(--color-danger); }
|
||||||
|
|
||||||
|
/* Кнопка «Ввести другую ссылку или номер» (экран «конференция завершена») —
|
||||||
|
единственная кнопка на экране БЕЗ формы-обёртки (Продолжить/Войти лежат
|
||||||
|
в <form>, которая как block-элемент задаёт им ширину; здесь у .btn нет
|
||||||
|
родителя-формы). .btn держит white-space: nowrap (shell.css) — на
|
||||||
|
остальных кнопках текст короткий и это не заметно, а здесь длинная фраза
|
||||||
|
выталкивала кнопку за правый край карточки на узких экранах. */
|
||||||
|
.flow-card > .btn-primary { width: 100%; white-space: normal; text-align: center; }
|
||||||
|
|
||||||
.link-back { display: block; text-align: center; margin-top: var(--space-5); font: var(--text-body); font-weight: 600; color: var(--color-ink-500); text-decoration: none; background: none; border: none; width: 100%; cursor: pointer; }
|
.link-back { display: block; text-align: center; margin-top: var(--space-5); font: var(--text-body); font-weight: 600; color: var(--color-ink-500); text-decoration: none; background: none; border: none; width: 100%; cursor: pointer; }
|
||||||
.link-back:hover { color: var(--color-ink-700); }
|
.link-back:hover { color: var(--color-ink-700); }
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ main.page-main { padding: var(--space-8); max-width: 1440px; margin: 0 auto; }
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
main.page-main { padding: var(--space-5) var(--space-4); }
|
main.page-main { padding: var(--space-5) var(--space-4); }
|
||||||
.page-head { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
|
.page-head { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
|
||||||
|
/* Длинные заголовки («Администрирование» и т.п.) иначе вылезают за край. */
|
||||||
|
.page-head h1 { font-size: clamp(22px, 6.5vw, 34px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Лобби-хаб (мозаика действий) ---------- */
|
/* ---------- Лобби-хаб (мозаика действий) ---------- */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -480,3 +480,37 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
--lk-grid-gap: var(--space-4);
|
--lk-grid-gap: var(--space-4);
|
||||||
--lk-control-bar-height: 0px;
|
--lk-control-bar-height: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Узкие экраны: топбар и тулбар комнаты ----------
|
||||||
|
ВАЖНО: этот блок обязан идти строго ПОСЛЕДНИМ в файле. Селекторы ниже
|
||||||
|
(`.tb-btn`, `.room-toolbar`) имеют ту же специфичность, что и безусловные
|
||||||
|
правила выше, а при равной специфичности побеждает порядок в исходнике, а
|
||||||
|
не факт совпадения media query — тот же грабли уже ловили в
|
||||||
|
auth.css/calendar.css/lobby.css (см. комментарии там). Первая версия этого
|
||||||
|
блока стояла в середине файла: `.room-invite-chip` (объявлен ВЫШЕ) скрылся,
|
||||||
|
а `.tb-btn`/`.room-toolbar` (объявлены НИЖЕ) продолжали побеждать — тулбар
|
||||||
|
на мобильном не менялся вовсе.
|
||||||
|
|
||||||
|
Топбар: скрываем название конференции и строку статуса — они и так есть
|
||||||
|
в самой конференции, а вот чип «№ … / Пригласить» с телефона нужен чаще
|
||||||
|
(скопировать ссылку и позвать участника), поэтому он остаётся.
|
||||||
|
|
||||||
|
Тулбар: демонстрация экрана, полноэкранный режим и мини-окно с телефона
|
||||||
|
практически не нужны, а все 8 кнопок в ширину экрана физически не
|
||||||
|
помещаются — скрываем их и уплотняем оставшиеся пять
|
||||||
|
(микрофон/камера/устройства/чат/выход). `flex-wrap` + `min-width: 0` —
|
||||||
|
страховка на совсем узких экранах, чтобы футер ни при каких подписях не
|
||||||
|
вылезал за ширину окна. */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.room-topbar { padding: var(--space-3) var(--space-4); justify-content: flex-end; }
|
||||||
|
.room-title-block { display: none; }
|
||||||
|
|
||||||
|
.tb-btn--screenshare,
|
||||||
|
.tb-btn--fullscreen,
|
||||||
|
.tb-btn--pip { display: none; }
|
||||||
|
|
||||||
|
.room-toolbar { padding: var(--space-3) var(--space-2); gap: 2px; flex-wrap: wrap; }
|
||||||
|
.tb-btn { min-width: 0; padding: 6px 6px; }
|
||||||
|
.tb-btn .icon-shell { width: 40px; height: 40px; }
|
||||||
|
.tb-btn span.label { font-size: 11px; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -265,6 +265,23 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-danger);
|
||||||
|
border: 1.5px solid var(--color-danger);
|
||||||
|
font: var(--text-body);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
}
|
||||||
|
.btn-danger:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Бейджи статуса ---------- */
|
/* ---------- Бейджи статуса ---------- */
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -393,6 +410,31 @@
|
|||||||
.modal-actions { display: flex; gap: var(--space-3); margin-top: var(--space-5); }
|
.modal-actions { display: flex; gap: var(--space-3); margin-top: var(--space-5); }
|
||||||
.modal-actions .btn { flex: 1; justify-content: center; }
|
.modal-actions .btn { flex: 1; justify-content: center; }
|
||||||
|
|
||||||
|
/* Опасное действие (удаление) — отдельно слева, Отмена/Сохранить — группой
|
||||||
|
справа, без растягивания на всю ширину (см. EditConferenceModal). */
|
||||||
|
.modal-actions.modal-actions--split { justify-content: space-between; }
|
||||||
|
.modal-actions--split > .btn-danger { flex: none; }
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* Узкий экран: «Удалить» + «Отмена» + «Сохранить» в один ряд не помещаются
|
||||||
|
(в сумме шире содержимого модалки на телефоне) — переносим «Удалить» на
|
||||||
|
свою строку сверху. */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.modal-actions--split { flex-wrap: wrap; }
|
||||||
|
.modal-actions--split > .btn-danger { flex: 1 1 100%; }
|
||||||
|
.modal-actions--split .modal-actions-group { flex: 1 1 100%; }
|
||||||
|
.modal-actions--split .row-actions { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Поля форм в модалках (общие для всех диалогов оболочки) ---------- */
|
/* ---------- Поля форм в модалках (общие для всех диалогов оболочки) ---------- */
|
||||||
.field { margin-bottom: var(--space-5); }
|
.field { margin-bottom: var(--space-5); }
|
||||||
.field:last-child { margin-bottom: 0; }
|
.field:last-child { margin-bottom: 0; }
|
||||||
|
|||||||
Reference in New Issue
Block a user