735 lines
26 KiB
TypeScript
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
|
|
}
|