Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1,232 @@
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 { 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>
{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>
)
}