`AppFooter` — версия инстанса из публичного `/api/health` (не требует авторизации, поэтому работает и на экранах входа; поля `version` может не быть — тогда версия не показывается) плюс копирайт с ТЕКУЩИМ годом и ссылкой на профиль автора. Подключён во все раскладки: `page-shell` (календарь, админка, профиль, мои конференции), `AuthLayout` (вход, регистрация, подтверждение email), `join-shell` и `lobby-shell`. В комнате конференции футера нет — там полноэкранное видео. Прежний футер админки (`.admin-footer`) удалён вместе со стилем — дубля не осталось. В брендовой панели экранов auth строка «© 2026 VidConf · self-hosted» ужата до «self-hosted»: год там был захардкожен, а два копирайта на одном экране — дубль.
235 lines
12 KiB
TypeScript
235 lines
12 KiB
TypeScript
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>
|
||
)
|
||
}
|