Files
vidconf/frontend/src/pages/MyConferencesPage.tsx
Max Ronzhin b6f57384b0 feat(ui): общий футер на всех страницах, кроме комнаты
`AppFooter` — версия инстанса из публичного `/api/health` (не требует
авторизации, поэтому работает и на экранах входа; поля `version` может не
быть — тогда версия не показывается) плюс копирайт с ТЕКУЩИМ годом и
ссылкой на профиль автора.

Подключён во все раскладки: `page-shell` (календарь, админка, профиль, мои
конференции), `AuthLayout` (вход, регистрация, подтверждение email),
`join-shell` и `lobby-shell`. В комнате конференции футера нет — там
полноэкранное видео.

Прежний футер админки (`.admin-footer`) удалён вместе со стилем — дубля не
осталось. В брендовой панели экранов auth строка «© 2026 VidConf ·
self-hosted» ужата до «self-hosted»: год там был захардкожен, а два
копирайта на одном экране — дубль.
2026-07-27 01:31:52 +03:00

235 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, CheckCircle2, Clock, Lock, Pencil, Play, Repeat } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
import { CopyPill } from '@/components/ui/CopyPill'
import { getMyConferences, type ConferenceOut } from '@/api/conferences'
import { formatNextOccurrence } from '@/lib/localTime'
import { addMinutesToTime, formatRecurrenceSummary } from '@/lib/recurrenceFormat'
import '@/styles/lobby.css'
import '@/styles/my-conferences.css'
type Filter = 'all' | 'pinned' | 'upcoming'
/** До начала разовой конференции можно войти заранее — порог «скоро начнётся» (UX-эвристика, не ограничение backend). */
const EARLY_JOIN_WINDOW_MS = 10 * 60 * 1000
function formatRelativeStart(iso: string): string {
const diffMs = new Date(iso).getTime() - Date.now()
const diffMin = Math.round(diffMs / 60_000)
if (diffMin <= 0) return 'вот-вот'
if (diffMin < 60) return `через ${diffMin} мин`
const diffHours = Math.round(diffMin / 60)
if (diffHours < 24) return `через ${diffHours} ч`
return `через ${Math.round(diffHours / 24)} дн.`
}
function formatSubtitle(conf: ConferenceOut): string {
if (conf.is_pinned && conf.recurrence) return formatRecurrenceSummary(conf.recurrence)
if (!conf.scheduled_at) return 'Разовая конференция'
const start = new Date(conf.scheduled_at)
const dateLabel = start.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })
const timeLabel = start.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
const endLabel = conf.duration_minutes ? addMinutesToTime(timeLabel, conf.duration_minutes) : null
return `Разовая · ${dateLabel}, ${timeLabel}${endLabel ? `${endLabel}` : ''}`
}
/**
* «Мои конференции» (design/mockups/my-conferences.html):
* закреплённые (повторяющиеся) и предстоящие разовые конференции, где текущий
* пользователь — организатор или приглашённый участник (решение от 2026-07-20,
* см. ADR-003); кнопки владельца скрываются по `is_owner`.
* Кнопка редактирования показывается только организатору (`is_owner`);
* состав участников — в ховер-карточке (ленивый запрос по наведению/фокусу).
*
* Контракт `/conferences/my` не возвращает завершённые конференции — фильтр
* «Завершённые» и карточка «Открыть саммари» из макета опущены.
*/
export function MyConferencesPage() {
const navigate = useNavigate()
const [filter, setFilter] = useState<Filter>('all')
const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null)
const [hoverTarget, setHoverTarget] = useState<{ conferenceId: string; anchorEl: HTMLElement } | null>(null)
const {
data: conferences,
isPending,
isError,
refetch,
} = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences })
const list = useMemo(() => conferences ?? [], [conferences])
const pinnedCount = list.filter((c) => c.is_pinned).length
const filtered = useMemo(() => {
if (filter === 'pinned') return list.filter((c) => c.is_pinned)
if (filter === 'upcoming') return list.filter((c) => !c.is_pinned)
return list
}, [list, filter])
function handleJoin(conf: ConferenceOut) {
navigate(`/j/${conf.slug}`)
}
return (
<div className="page-shell">
<ShellTopbar />
<main className="page-main">
<div className="page-head">
<div>
<h1>Мои конференции</h1>
<p>
{list.length} {list.length === 1 ? 'конференция' : 'конференций'} · {pinnedCount} закреплённых с повторением
</p>
</div>
<button type="button" className="btn btn-primary" onClick={() => navigate('/calendar')}>
Запланировать новую
</button>
</div>
<div className="filters">
<button type="button" className={`chip${filter === 'all' ? ' is-active' : ''}`} onClick={() => setFilter('all')}>
Все
</button>
<button type="button" className={`chip${filter === 'pinned' ? ' is-active' : ''}`} onClick={() => setFilter('pinned')}>
<Repeat style={{ width: 14, height: 14 }} aria-hidden="true" />
Закреплённые
</button>
<button type="button" className={`chip${filter === 'upcoming' ? ' is-active' : ''}`} onClick={() => setFilter('upcoming')}>
<Clock style={{ width: 14, height: 14 }} aria-hidden="true" />
Предстоящие
</button>
</div>
{isPending ? (
<p className="field-hint">Загрузка конференций</p>
) : isError ? (
<div className="error-banner">
<AlertTriangle style={{ width: 18, height: 18 }} aria-hidden="true" />
<div style={{ flex: 1 }}>Не удалось загрузить конференции. Проверьте соединение и попробуйте ещё раз.</div>
<button type="button" className="btn btn-secondary" style={{ width: 'auto' }} onClick={() => refetch()}>
Повторить
</button>
</div>
) : filtered.length === 0 ? (
<p className="field-hint">Здесь пока пусто запланируйте конференцию в календаре.</p>
) : (
<section className="conf-grid">
{filtered.map((conf) => {
const joinable =
conf.is_pinned || conf.status === 'active' || (conf.scheduled_at
? new Date(conf.scheduled_at).getTime() - new Date().getTime() <= EARLY_JOIN_WINDOW_MS
: false)
const link = `${window.location.origin}/j/${conf.slug}`
return (
<article
className={`conf-card${conf.status === 'ended' ? ' is-ended' : ''}`}
key={conf.id}
onMouseEnter={(e) => setHoverTarget({ conferenceId: conf.id, anchorEl: e.currentTarget })}
onMouseLeave={() => setHoverTarget((prev) => (prev?.conferenceId === conf.id ? null : prev))}
onFocus={(e) => setHoverTarget({ conferenceId: conf.id, anchorEl: e.currentTarget })}
onBlur={() => setHoverTarget((prev) => (prev?.conferenceId === conf.id ? null : prev))}
>
<div className="conf-card-head">
<div className="conf-title-wrap">
<h3>
{conf.is_closed && <Lock style={{ width: 15, height: 15, flexShrink: 0 }} aria-hidden="true" />}
{conf.title ?? 'Конференция без названия'}
</h3>
<p>{formatSubtitle(conf)}</p>
</div>
{conf.is_pinned ? (
<span className="conf-badge pinned">
<Repeat style={{ width: 12, height: 12 }} aria-hidden="true" />
Закреплена
</span>
) : conf.status === 'active' ? (
<span className="conf-badge live">
<Play style={{ width: 12, height: 12 }} aria-hidden="true" />
Идёт сейчас
</span>
) : conf.status === 'ended' ? (
<span className="conf-badge ended">
<CheckCircle2 style={{ width: 12, height: 12 }} aria-hidden="true" />
Завершена
</span>
) : (
<span className="conf-badge scheduled">
<Clock style={{ width: 12, height: 12 }} aria-hidden="true" />
Предстоящая
</span>
)}
</div>
{conf.is_pinned && conf.next_occurrence && (
<div className="conf-meta-row">
<span className="meta-item">
<Clock style={{ width: 15, height: 15 }} aria-hidden="true" />
Ближайшее: {formatNextOccurrence(conf.next_occurrence)}
</span>
</div>
)}
{conf.status !== 'ended' && (
<div className="pills-row">
<CopyPill label="Ссылка" value={link.replace(/^https?:\/\//, '')} copyText={link} />
<CopyPill label="Номер" value={conf.number} copyText={conf.number} />
</div>
)}
<div className="conf-card-footer">
{conf.status === 'ended' ? (
<button type="button" className="btn btn-secondary" style={{ flex: 1 }} disabled>
Конференция завершена
</button>
) : joinable ? (
<button type="button" className="btn btn-primary" onClick={() => handleJoin(conf)}>
{conf.is_closed ? (
<Lock style={{ width: 16, height: 16 }} aria-hidden="true" />
) : (
<Play style={{ width: 16, height: 16 }} aria-hidden="true" />
)}
{conf.is_closed ? 'Войти по паролю' : 'Войти'}
</button>
) : (
<button type="button" className="btn btn-secondary" style={{ flex: 1 }} disabled>
<Clock style={{ width: 16, height: 16 }} aria-hidden="true" />
Начнётся {formatRelativeStart(conf.scheduled_at!)}
</button>
)}
{conf.status !== 'ended' && conf.is_owner && (
<button type="button" className="icon-btn" aria-label="Редактировать" onClick={() => setEditingConference(conf)}>
<Pencil style={{ width: 17, height: 17 }} aria-hidden="true" />
</button>
)}
</div>
</article>
)
})}
</section>
)}
</main>
<AppFooter />
{editingConference && (
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={() => setEditingConference(null)}>
<div className="modal-form-wrap" onClick={(e) => e.stopPropagation()}>
<ConferenceFormCard
conference={editingConference}
onSaved={() => setEditingConference(null)}
onDeleted={() => setEditingConference(null)}
onCancelEdit={() => setEditingConference(null)}
/>
</div>
</div>
)}
<ConferenceHoverCard conferenceId={hoverTarget?.conferenceId ?? null} anchorEl={hoverTarget?.anchorEl ?? null} />
</div>
)
}