Files
akademiawodociagowca/frontend/src/pages/webinars/HostConsolePage.tsx
T
2026-09-02 08:21:27 +02:00

735 lines
26 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Client, type IMessage } from '@stomp/stompjs'
import { client, errorMessage, getToken } from '../../api/client'
import type { Webinar } from '../../api/types'
import { Icon } from '../../components/Icon'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, ErrorState, Input, Spinner, Textarea } from '../../components/ui'
import type { InteractionView, ModeratorInteractionView } from '../../lib/useInteractions'
import { SpeakQueue, type SpeakRequestView } from './SpeakQueue'
import { panelPath } from '../../lib/routes'
/**
* Panel prowadzacego: czat na zywo i kolejka pytan.
*
* Aktualizacja idzie tym samym WebSocketem co u uczestnikow, **bez odpytywania
* serwera co kilka sekund**: prowadzacy patrzy na ten ekran przez cala godzine,
* wiec odpytywanie oznaczaloby setki zbednych zadan.
*
* Prowadzacy slucha osobnego tematu moderacji, na ktory ida takze pytania
* jeszcze niezatwierdzone. Uczestnicy tego tematu nie widza.
*/
type ModerationData = {
chat: ModeratorInteractionView[]
questions: ModeratorInteractionView[]
}
/**
* Sygnal dzwiekowy przy nowym zapytaniu ofertowym.
*
* Skladany w WebAudio, a nie wczytywany z pliku: chodzi o jeden krotki ton,
* a plik oznaczalby dodatkowy zasob do wgrania i podania sciezki. Dzwiek jest
* krotki i cichy: ma zwrocic uwage prowadzacego w trakcie mowienia, a nie
* przestraszyc jego i sale.
*
* Przegladarki blokuja dzwiek do pierwszej interakcji uzytkownika, wiec blad
* jest tu spodziewany i pomijany. Prowadzacy i tak zdazy kliknac w panelu.
*/
function playChime() {
try {
const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
const ctx = new Ctx()
const oscillator = ctx.createOscillator()
const gain = ctx.createGain()
oscillator.type = 'sine'
oscillator.frequency.value = 880
gain.gain.setValueAtTime(0.0001, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.12, ctx.currentTime + 0.02)
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.35)
oscillator.connect(gain)
gain.connect(ctx.destination)
oscillator.start()
oscillator.stop(ctx.currentTime + 0.36)
oscillator.onended = () => void ctx.close()
} catch {
// dzwiek jest udogodnieniem, nie warunkiem dzialania panelu
}
}
/** Klucz zapamietanego wyboru: prowadzacy ustawia to raz, nie na kazdym webinarze. */
const SOUND_KEY = 'host-console-sound'
export function HostConsolePage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const toasts = useToasts()
const [chat, setChat] = useState<ModeratorInteractionView[]>([])
const [questions, setQuestions] = useState<ModeratorInteractionView[]>([])
const [connected, setConnected] = useState(false)
/** Ile pozycji przyszlo od ostatniego spojrzenia. Zerowane klikiem. */
const [newChat, setNewChat] = useState(0)
const [newQuestions, setNewQuestions] = useState(0)
/** Zapytania ofertowe licza sie osobno: to kontakt handlowy, nie pytanie. */
const [newOffers, setNewOffers] = useState(0)
/*
* Sygnal dzwiekowy przy zapytaniu ofertowym, wylaczalny.
*
* Domyslnie wlaczony, bo zapytanie ofertowe jest tym zgloszeniem, ktorego
* prowadzacy najbardziej nie chce przegapic, a w trakcie mowienia nie patrzy
* na panel. Wybor zapamietujemy w przegladarce: ustawia sie go raz, a nie
* przy kazdym webinarze.
*/
const [sound, setSound] = useState(() => {
try {
return window.localStorage.getItem(SOUND_KEY) !== 'off'
} catch {
return true
}
})
const soundRef = useRef(sound)
soundRef.current = sound
function toggleSound() {
setSound((on) => {
const next = !on
try {
window.localStorage.setItem(SOUND_KEY, next ? 'on' : 'off')
} catch {
// brak pamieci przegladarki nie moze zepsuc panelu
}
return next
})
}
/** Kolejka zgloszen do glosu. */
const [speakRequests, setSpeakRequests] = useState<SpeakRequestView[]>([])
/** Tryb otwartego mikrofonu, wczytywany razem z danymi webinaru. */
const [openMic, setOpenMic] = useState(false)
/** Tryb "wideo dla wszystkich", wczytywany razem z danymi webinaru. */
const [openVideo, setOpenVideo] = useState(false)
const loadSpeakQueue = useCallback(async () => {
if (!id) return
try {
const response = await client.get<SpeakRequestView[]>(
`/admin/webinars/${id}/speak-requests`,
)
setSpeakRequests(response.data)
} catch {
// kolejka jest dodatkiem do panelu: jej brak nie moze zatrzymac moderacji
}
}, [id])
useEffect(() => {
void loadSpeakQueue()
}, [loadSpeakQueue])
const webinar = useQuery({
queryKey: ['webinar', id],
queryFn: async () => {
const response = await client.get<Webinar>(`/admin/webinars/${id}`)
return response.data
},
})
const initial = useQuery({
queryKey: ['moderation', id],
queryFn: async () => {
const response = await client.get<ModerationData>(`/admin/webinars/${id}/interactions`)
return response.data
},
})
useEffect(() => {
if (!initial.data) return
setChat(initial.data.chat)
setQuestions(initial.data.questions)
}, [initial.data])
useEffect(() => {
if (webinar.data) {
setOpenMic(webinar.data.openMic ?? false)
setOpenVideo(webinar.data.openVideo ?? false)
}
}, [webinar.data])
useEffect(() => {
if (!id) return
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const stomp = new Client({
brokerURL: `${protocol}//${window.location.host}/ws`,
// prowadzacy nie ma tokenu zapisu, bo nie jest uczestnikiem: przedstawia
// sie tym samym JWT co przy zwyklych zadaniach panelu
connectHeaders: { 'X-Panel-Token': getToken() ?? '' },
reconnectDelay: 4000,
onConnect: () => {
setConnected(true)
stomp.subscribe(`/topic/webinars/${id}/moderation`, (message: IMessage) => {
const event = JSON.parse(message.body) as { kind: string; payload: InteractionView }
apply(event)
})
/*
* Kolejka glosu odswieza sie ze zdarzenia, a nie z odpytywania serwera:
* prowadzacy ma zobaczyc podniesiona reke od razu, a nie po minucie.
*/
stomp.subscribe(`/topic/webinars/${id}/speak`, () => {
void loadSpeakQueue()
})
},
onDisconnect: () => setConnected(false),
onWebSocketClose: () => setConnected(false),
})
function apply(event: { kind: string; payload: InteractionView }) {
// zdarzenie WS niesie publiczny widok bez danych moderacji autora.
// Uzupelniamy je do widoku moderatora wartosciami domyslnymi: przyciski
// moderacji uzyja pelnych danych po najblizszym odswiezeniu listy (robimy
// je po kazdej akcji mute/ban), a nowa wiadomosc i tak jest widoczna od razu.
const item: ModeratorInteractionView = {
...event.payload,
authorRegistrationId: null,
authorChatMuted: false,
authorRoomBanned: false,
}
// zapytanie ofertowe ma wlasny licznik i wlasna kolumne, zeby nie ginelo
// w kolejce pytan: to jedyne zgloszenie, ktore jest kontaktem handlowym
if (item.type === 'OFFER_REQUEST') {
setNewOffers((n) => n + 1)
// referencja, a nie stan: efekt WebSocketu widzi wartosc z chwili
// podlaczenia, wiec przelacznik nie dzialalby bez zerwania polaczenia
if (soundRef.current) playChime()
return
}
if (item.type === 'CHAT') {
if (event.kind === 'REJECTED') {
setChat((current) => current.filter((c) => c.id !== item.id))
return
}
setChat((current) =>
current.some((c) => c.id === item.id) ? current : [...current, item])
setNewChat((n) => n + 1)
return
}
setQuestions((current) => {
const exists = current.some((q) => q.id === item.id)
if (!exists) {
setNewQuestions((n) => n + 1)
return [item, ...current]
}
return current.map((q) => (q.id === item.id ? item : q))
})
}
stomp.activate()
return () => {
void stomp.deactivate()
}
}, [id, loadSpeakQueue])
async function act(interactionId: number, action: string, body?: unknown) {
try {
await client.post(`/admin/webinars/${id}/interactions/${interactionId}/${action}`, body ?? {})
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się wykonać operacji'))
}
}
/**
* Wyciszenie/przywrocenie glosu albo blokada/odblokada uczestnika.
*
* Po akcji odswiezamy liste czatu, zeby przyciski przy KAZDEJ wiadomosci tego
* autora pokazaly aktualny stan (zdarzenie WS niesie tylko publiczny widok
* bez danych moderacji). To pojedyncze odswiezenie, nie odpytywanie w petli.
*/
async function moderateParticipant(
registrationId: number,
kind: 'mute' | 'ban',
value: boolean,
) {
try {
await client.post(`/admin/webinars/${id}/interactions/participants/${registrationId}/${kind}`, {
value,
})
const fresh = await initial.refetch()
if (fresh.data) {
setChat(fresh.data.chat)
setQuestions(fresh.data.questions)
}
toasts.success(
kind === 'mute'
? value ? 'Uczestnik wyciszony' : 'Przywrócono głos'
: value ? 'Uczestnik zablokowany' : 'Odblokowano uczestnika',
)
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się wykonać operacji'))
}
}
if (webinar.isPending || initial.isPending) return <Spinner />
if (webinar.isError) {
return <ErrorState message={errorMessage(webinar.error, 'Nie udało się wczytać webinaru')} />
}
const pending = questions.filter((q) => q.status === 'NEW')
const published = questions
.filter((q) => q.status === 'APPROVED' || q.status === 'ANSWERED')
.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
return b.votes - a.votes
})
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-2">
<Button variant="ghost" icon="arrow-left" onClick={() => navigate(panelPath('/webinary'))}>
Wróć
</Button>
<div>
<h1 className="text-xl font-semibold text-slate-900">Prowadzenie spotkania</h1>
<p className="text-sm text-slate-500">{webinar.data.title}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleSound}
aria-pressed={sound}
title={sound ? 'Wyłącz sygnał dźwiękowy' : 'Włącz sygnał dźwiękowy'}
className={`flex h-11 w-11 items-center justify-center rounded-md transition-colors duration-fast ease-standard ${
sound
? 'bg-blue-50 text-blue-700 hover:bg-blue-100'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
}`}
>
<Icon name={sound ? 'volume' : 'volume-off'} size={18} />
</button>
<Badge tone={connected ? 'ok' : 'warn'}>
{connected ? 'połączono' : 'łączenie...'}
</Badge>
</div>
</div>
{newOffers > 0 && (
<Link
to={panelPath('/zapytania')}
className="flex items-center gap-2 rounded-lg border border-blue-300 bg-blue-50 p-3 text-sm text-blue-900 hover:bg-blue-100"
>
<Icon name="send" size={16} />
<span className="font-medium">
{newOffers === 1
? 'Nowe zapytanie ofertowe'
: `Nowe zapytania ofertowe: ${newOffers}`}
</span>
<span className="ml-auto text-xs">Przejdź do listy</span>
</Link>
)}
{/* trzy kolumny od xl, dwie od lg, jedna pod spodem na wezszym ekranie */}
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
<SpeakQueue
webinarId={id ?? ''}
requests={speakRequests}
openMic={openMic}
onOpenMicChange={setOpenMic}
openVideo={openVideo}
onOpenVideoChange={setOpenVideo}
onChanged={() => void loadSpeakQueue()}
/>
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
<header
className="flex items-center justify-between border-b border-slate-200 px-4 py-3"
onMouseEnter={() => setNewChat(0)}
>
<h2 className="text-sm font-semibold text-slate-900">Czat na żywo</h2>
<span className="hx-data text-xs text-slate-400">
{chat.length}
{newChat > 0 && <span className="ml-2 text-blue-700">+{newChat}</span>}
</span>
</header>
<ChatColumn
messages={chat}
onReject={(itemId) => act(itemId, 'reject')}
onModerate={moderateParticipant}
/>
</section>
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
<header
className="flex items-center justify-between border-b border-slate-200 px-4 py-3"
onMouseEnter={() => setNewQuestions(0)}
>
<h2 className="text-sm font-semibold text-slate-900">Kolejka pytań</h2>
<span className="hx-data text-xs text-slate-400">
{pending.length} oczekuje
{newQuestions > 0 && <span className="ml-2 text-blue-700">+{newQuestions}</span>}
</span>
</header>
<QuestionsColumn
pending={pending}
published={published}
onAct={act}
/>
</section>
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
<header className="border-b border-slate-200 px-4 py-3">
<h2 className="text-sm font-semibold text-slate-900">Zadania</h2>
</header>
<TasksColumn webinarId={Number(id)} />
</section>
</div>
</div>
)
}
function ChatColumn({
messages,
onReject,
onModerate,
}: {
messages: ModeratorInteractionView[]
onReject: (id: number) => void
onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean) => void
}) {
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [messages.length])
return (
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
{messages.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">Czat jest pusty.</p>
)}
{messages.map((message) => (
<div key={message.id} className="group flex items-start gap-2 text-sm">
<div className="min-w-0 flex-1">
<span
className={`font-medium ${message.fromSpeaker ? 'text-blue-700' : 'text-slate-700'}`}
>
{message.authorName}
</span>
{message.authorChatMuted && (
<span className="ml-1.5 rounded bg-warn-50 px-1.5 py-0.5 text-2xs font-medium text-warn-600">
wyciszony
</span>
)}
{message.authorRoomBanned && (
<span className="ml-1.5 rounded bg-alarm-50 px-1.5 py-0.5 text-2xs font-medium text-alarm-600">
zablokowany
</span>
)}
<p className="whitespace-pre-wrap break-words text-slate-600">{message.content}</p>
</div>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{/* moderacja autora tylko dla wiadomosci uczestnika (nie prelegenta) */}
{message.authorRegistrationId !== null && (
<>
<button
type="button"
onClick={() =>
onModerate(message.authorRegistrationId!, 'mute', !message.authorChatMuted)
}
aria-label={message.authorChatMuted ? 'Przywróć głos na czacie' : 'Wycisz na czacie'}
title={message.authorChatMuted ? 'Przywróć głos na czacie' : 'Wycisz na czacie'}
className="rounded-md p-1 text-warn-600 hover:bg-warn-50"
>
<Icon name={message.authorChatMuted ? 'mic' : 'mic-off'} size={14} />
</button>
<button
type="button"
onClick={() =>
onModerate(message.authorRegistrationId!, 'ban', !message.authorRoomBanned)
}
aria-label={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj uczestnika'}
title={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj dostęp do spotkania'}
className="rounded-md p-1 text-alarm-600 hover:bg-alarm-50"
>
<Icon name={message.authorRoomBanned ? 'user-plus' : 'user-off'} size={14} />
</button>
</>
)}
<button
type="button"
onClick={() => onReject(message.id)}
aria-label="Usuń wiadomość"
title="Usuń wiadomość"
className="rounded-md p-1 text-alarm-600 hover:bg-alarm-50"
>
<Icon name="trash" size={14} />
</button>
</div>
</div>
))}
<div ref={bottomRef} />
</div>
)
}
function QuestionsColumn({
pending,
published,
onAct,
}: {
pending: ModeratorInteractionView[]
published: ModeratorInteractionView[]
onAct: (id: number, action: string, body?: unknown) => void
}) {
return (
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
{pending.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-warn-600">
Oczekuje na zatwierdzenie
</p>
{pending.map((question) => (
<div key={question.id} className="rounded-md border border-warn-500/30 bg-warn-50 p-2.5">
<p className="whitespace-pre-wrap break-words text-sm text-slate-700">
{question.content}
</p>
<p className="mt-0.5 text-xs text-slate-500">{question.authorName}</p>
<div className="mt-2 flex gap-2">
<Button size="sm" icon="check" onClick={() => onAct(question.id, 'approve')}>
Zatwierdź
</Button>
<Button
size="sm"
variant="ghost"
icon="x"
onClick={() => onAct(question.id, 'reject')}
className="text-alarm-600 hover:bg-alarm-100"
>
Odrzuć
</Button>
</div>
</div>
))}
</div>
)}
{published.length === 0 && pending.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">Nie ma jeszcze pytań.</p>
)}
{published.map((question) => (
<QuestionCard key={question.id} question={question} onAct={onAct} />
))}
</div>
)
}
function QuestionCard({
question,
onAct,
}: {
question: ModeratorInteractionView
onAct: (id: number, action: string, body?: unknown) => void
}) {
const [answering, setAnswering] = useState(false)
const [answer, setAnswer] = useState(question.answer ?? '')
return (
<div
className={`rounded-md border p-2.5 ${
question.pinned ? 'border-blue-300 bg-blue-50' : 'border-slate-200'
}`}
>
<div className="flex items-start gap-2">
<span className="hx-data flex w-10 shrink-0 flex-col items-center rounded-md bg-slate-100 py-1 text-xs text-slate-600">
<Icon name="chevron-up" size={12} />
{question.votes}
</span>
<div className="min-w-0 flex-1">
<p className="whitespace-pre-wrap break-words text-sm text-slate-700">
{question.content}
</p>
<p className="mt-0.5 text-xs text-slate-400">{question.authorName}</p>
{question.answer && !answering && (
<div className="mt-2 rounded-md bg-ok-50 p-2">
<p className="text-xs font-medium text-ok-600">Odpowiedziano</p>
<p className="whitespace-pre-wrap break-words text-sm text-slate-700">
{question.answer}
</p>
</div>
)}
{answering && (
<div className="mt-2 space-y-2">
<Textarea
rows={2}
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Odpowiedź widoczna dla uczestników..."
/>
<div className="flex gap-2">
<Button
size="sm"
icon="check"
disabled={!answer.trim()}
onClick={() => {
onAct(question.id, 'answer', { answer })
setAnswering(false)
}}
>
Zapisz odpowiedź
</Button>
<Button size="sm" variant="ghost" onClick={() => setAnswering(false)}>
Anuluj
</Button>
</div>
</div>
)}
{!answering && (
<div className="mt-2 flex flex-wrap gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => onAct(question.id, 'pin', { pinned: !question.pinned })}
>
{question.pinned ? 'Odepnij' : 'Przypnij'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setAnswering(true)}>
{question.answer ? 'Zmień odpowiedź' : 'Odpowiedz'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onAct(question.id, 'reject')}
className="text-alarm-600 hover:bg-alarm-50"
>
Ukryj
</Button>
</div>
)}
</div>
</div>
</div>
)
}
/**
* Zadania ogloszone w trakcie spotkania.
*
* Formularz jest krotki celowo: prowadzacy oglasza zadanie na zywo, w trakcie
* mowienia, wiec ma miec tytul i ewentualnie limit, a nie pelen formularz.
*/
function TasksColumn({ webinarId }: { webinarId: number }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const [title, setTitle] = useState('')
const [capacity, setCapacity] = useState('')
const tasks = useQuery({
queryKey: ['tasks', webinarId],
queryFn: async () => {
const response = await client.get<TaskRow[]>(`/admin/webinars/${webinarId}/tasks`)
return response.data
},
})
const create = useMutation({
mutationFn: () =>
client.post(`/admin/webinars/${webinarId}/tasks`, {
title,
capacity: capacity ? Number(capacity) : null,
}),
onSuccess: () => {
setTitle('')
setCapacity('')
queryClient.invalidateQueries({ queryKey: ['tasks', webinarId] })
},
onError: (error: unknown) => toasts.error(errorMessage(error, 'Nie udało się dodać zadania')),
})
const toggle = useMutation({
mutationFn: ({ id, open }: { id: number; open: boolean }) =>
client.post(`/admin/webinars/${webinarId}/tasks/${id}/open`, { open }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks', webinarId] }),
onError: (error: unknown) => toasts.error(errorMessage(error, 'Nie udało się zmienić zadania')),
})
return (
<>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
{tasks.isPending && <Spinner />}
{tasks.data?.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">
Nie ogłoszono jeszcze żadnych zadań.
</p>
)}
{tasks.data?.map((task) => (
<div key={task.id} className="rounded-md border border-slate-200 p-2.5">
<p className="text-sm font-medium text-slate-900">{task.title}</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<span className="hx-data text-xs text-slate-500">
{task.capacity === null
? `${task.taken} zapisanych`
: `${task.taken} / ${task.capacity}`}
</span>
<Button
size="sm"
variant="ghost"
className="ml-auto"
onClick={() => toggle.mutate({ id: task.id, open: !task.open })}
>
{task.open ? 'Zamknij zapisy' : 'Otwórz zapisy'}
</Button>
</div>
</div>
))}
</div>
<div className="space-y-2 border-t border-slate-200 p-3">
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Tytuł zadania"
/>
<div className="flex gap-2">
<Input
type="number"
min={1}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
placeholder="Limit miejsc (opcjonalnie)"
/>
<Button
icon="plus"
size="sm"
onClick={() => create.mutate()}
disabled={!title.trim() || create.isPending}
>
Ogłoś
</Button>
</div>
</div>
</>
)
}
type TaskRow = {
id: number
title: string
capacity: number | null
taken: number
open: boolean
}