Первоначальная версия VidConf
This commit is contained in:
232
frontend/src/pages/MyConferencesPage.tsx
Normal file
232
frontend/src/pages/MyConferencesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user