Initial commit
This commit is contained in:
@@ -0,0 +1,734 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user