Panel prowadzenia: lista osob na sali i usuwanie po stronie serwera
Deploy PROD / deploy (push) Successful in 26s
Deploy PROD / deploy (push) Successful in 26s
Karta "Na sali" pokazuje liczbe i liste obecnych (przy LiveKit prosto z serwera pokoju, przy innych dostawcach z sygnalow obecnosci), z wyszukiwaniem, paginacja, wyciszeniem na czacie i usuwaniem z sali. Usuniecie uczestnika z zapisu blokuje go i wyrzuca przez removeParticipant; dotad sale opuszczala tylko posluszna przegladarka. Blokada z czatu robi to samo i pyta o potwierdzenie. Zablokowany nie pisze juz na czacie. Prowadzacego nie da sie usunac. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Badge, Input } from '../../components/ui'
|
||||
import { formatTimeOnly, plural } from '../../lib/dates'
|
||||
|
||||
/** Jedna osoba na sali, tak jak zwraca `RoomPresenceService.ParticipantView`. */
|
||||
type Participant = {
|
||||
identity: string
|
||||
name: string
|
||||
kind: 'SPEAKER' | 'PARTICIPANT' | 'OTHER'
|
||||
registrationId: number | null
|
||||
email: string | null
|
||||
joinedAt: string | null
|
||||
audio: boolean
|
||||
video: boolean
|
||||
screen: boolean
|
||||
chatMuted: boolean
|
||||
roomBanned: boolean
|
||||
}
|
||||
|
||||
type Presence = {
|
||||
source: 'LIVEKIT' | 'ATTENDANCE'
|
||||
total: number
|
||||
speakers: number
|
||||
participants: number
|
||||
others: number
|
||||
items: Participant[]
|
||||
page: number
|
||||
size: number
|
||||
totalPages: number
|
||||
totalElements: number
|
||||
banned: { registrationId: number; name: string; email: string }[]
|
||||
problem: string | null
|
||||
checkedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Kto jest teraz na sali.
|
||||
*
|
||||
* ODPYTYWANIE, NIE WEBSOCKET
|
||||
* ------------------------------------------------------------------------
|
||||
* Wejscie i wyjscie z pokoju widzi serwer wideo, nie nasz backend: nie ma
|
||||
* zdarzenia, ktore moglibysmy rozeslac. Lista jest wiec pobierana co dziesiec
|
||||
* sekund. Dokladnosc do kilku sekund wystarcza, a jedno lekkie zapytanie na
|
||||
* prowadzacego nie obciaza niczego.
|
||||
*
|
||||
* USUNIECIE TO BLOKADA
|
||||
* ------------------------------------------------------------------------
|
||||
* Samo wyrzucenie nic nie daje, bo uczestnik wrocilby tym samym linkiem.
|
||||
* Dlatego "Usun z sali" blokuje zapis i wyrzuca polaczenie po stronie serwera.
|
||||
* Blokade zdejmuje sie z listy "Zablokowani" na dole karty. Operacja jest
|
||||
* odwracalna, wiec potwierdzenie nie jest oznaczone jako nieodwracalne.
|
||||
*/
|
||||
export function RoomParticipants({ webinarId, timezone }: { webinarId: string; timezone: string }) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
const [showBanned, setShowBanned] = useState(false)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
|
||||
// wyszukiwanie po chwili bezruchu, zeby nie odpytywac serwera przy kazdej literze
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setQuery(search.trim())
|
||||
// zmiana frazy cofa na pierwsza strone, tak jak kazdy filtr
|
||||
setPage(0)
|
||||
}, 300)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [search])
|
||||
|
||||
const presence = useQuery({
|
||||
queryKey: ['room-presence', webinarId, query, page],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await client.get<Presence>(`/admin/webinars/${webinarId}/room/participants`, {
|
||||
params: { q: query || undefined, page, size: PAGE_SIZE },
|
||||
})
|
||||
).data,
|
||||
refetchInterval: 10_000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['room-presence', webinarId] })
|
||||
|
||||
async function remove(participant: Participant) {
|
||||
const fromRegistration = participant.kind === 'PARTICIPANT'
|
||||
const ok = await confirm({
|
||||
title: `Usunąć ${participant.name} z sali?`,
|
||||
message: fromRegistration
|
||||
? 'Uczestnik zostanie wyprowadzony ze spotkania i nie wejdzie ponownie swoim linkiem. '
|
||||
+ 'Straci też możliwość pisania na czacie. Blokadę można zdjąć w tym panelu, '
|
||||
+ 'na liście „Zablokowani”.'
|
||||
: 'To połączenie nie pochodzi z zapisu na webinar, więc nie da się go zablokować. '
|
||||
+ 'Zostanie rozłączone, ale może wrócić, jeśli ma własny dostęp do serwera wideo.',
|
||||
confirmLabel: 'Usuń z sali',
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
setBusy(participant.identity)
|
||||
try {
|
||||
const { data } = await client.post<{ banned: boolean; removedFromRoom: boolean }>(
|
||||
`/admin/webinars/${webinarId}/room/participants/remove`,
|
||||
{ identity: participant.identity },
|
||||
)
|
||||
if (data.removedFromRoom) {
|
||||
toasts.success(`Usunięto z sali: ${participant.name}`)
|
||||
} else if (data.banned) {
|
||||
toasts.success(`Zablokowano: ${participant.name}. Przeglądarka tej osoby opuści salę.`)
|
||||
} else {
|
||||
toasts.success('Tej osoby nie było już na sali')
|
||||
}
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się usunąć z sali'))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleChat(participant: Participant) {
|
||||
if (participant.registrationId === null) return
|
||||
setBusy(participant.identity)
|
||||
try {
|
||||
await client.post(
|
||||
`/admin/webinars/${webinarId}/interactions/participants/${participant.registrationId}/mute`,
|
||||
{ value: !participant.chatMuted },
|
||||
)
|
||||
toasts.success(participant.chatMuted ? 'Przywrócono pisanie na czacie' : 'Wyciszono na czacie')
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się zmienić wyciszenia'))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function unban(registrationId: number, name: string) {
|
||||
setBusy(`ban-${registrationId}`)
|
||||
try {
|
||||
await client.post(
|
||||
`/admin/webinars/${webinarId}/interactions/participants/${registrationId}/ban`,
|
||||
{ value: false },
|
||||
)
|
||||
toasts.success(`Odblokowano: ${name}. Może ponownie wejść na salę.`)
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się odblokować'))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const data = presence.data
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold text-slate-900">Na sali</h2>
|
||||
<span className="hx-data text-sm font-semibold text-slate-900">
|
||||
{data ? data.total : '...'}
|
||||
<span className="ml-1 font-sans text-xs font-normal text-slate-500">
|
||||
{data ? plural(data.total, 'osoba', 'osoby', 'osób') : ''}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{data && (
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
Prowadzący: <span className="hx-data">{data.speakers}</span>
|
||||
{' · '}Uczestnicy: <span className="hx-data">{data.participants}</span>
|
||||
{data.others > 0 && (
|
||||
<>
|
||||
{' · '}Spoza zapisu: <span className="hx-data">{data.others}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="relative mt-2">
|
||||
<Icon
|
||||
name="search"
|
||||
size={16}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Szukaj po nazwisku lub adresie"
|
||||
aria-label="Szukaj osoby na sali"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{data?.problem && <p className="bg-warn-50 px-4 py-2 text-xs text-warn-600">{data.problem}</p>}
|
||||
{data?.source === 'ATTENDANCE' && !data.problem && (
|
||||
<p className="bg-slate-50 px-4 py-2 text-xs text-slate-500">
|
||||
Lista według sygnałów obecności ze strony spotkania, odświeżanych co minutę.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{presence.isPending && <p className="py-6 text-center text-sm text-slate-400">Wczytywanie...</p>}
|
||||
{presence.isError && (
|
||||
<p className="py-6 text-center text-sm text-alarm-600">
|
||||
{errorMessage(presence.error, 'Nie udało się wczytać listy obecnych')}
|
||||
</p>
|
||||
)}
|
||||
{data && data.items.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
{query ? 'Nikt na sali nie pasuje do wyszukiwania.' : 'Na sali nikogo jeszcze nie ma.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{data?.items.map((participant) => (
|
||||
<li key={participant.identity} className="flex items-center gap-2 px-4 py-2 text-sm">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="truncate font-medium text-slate-800">{participant.name}</span>
|
||||
{participant.kind === 'SPEAKER' && <Badge tone="brand">prowadzący</Badge>}
|
||||
{participant.kind === 'OTHER' && <Badge tone="warn">spoza zapisu</Badge>}
|
||||
{participant.chatMuted && <Badge tone="warn">wyciszony</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2 text-xs text-slate-500">
|
||||
{participant.email && <span className="truncate">{participant.email}</span>}
|
||||
{participant.joinedAt && (
|
||||
<span className="hx-data">od {formatTimeOnly(participant.joinedAt, timezone)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* co osoba NADAJE w tej chwili, nie co ma prawo nadawac */}
|
||||
<div className="flex shrink-0 items-center gap-1 text-slate-400">
|
||||
{participant.audio && (
|
||||
<span title="Mówi (mikrofon włączony)" className="text-ok-600">
|
||||
<Icon name="mic" size={14} />
|
||||
<span className="sr-only">mikrofon włączony</span>
|
||||
</span>
|
||||
)}
|
||||
{participant.video && (
|
||||
<span title="Kamera włączona" className="text-ok-600">
|
||||
<Icon name="video" size={14} />
|
||||
<span className="sr-only">kamera włączona</span>
|
||||
</span>
|
||||
)}
|
||||
{participant.screen && (
|
||||
<span title="Udostępnia ekran" className="text-ok-600">
|
||||
<Icon name="presentation" size={14} />
|
||||
<span className="sr-only">udostępnia ekran</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{participant.kind !== 'SPEAKER' && (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{participant.kind === 'PARTICIPANT' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === participant.identity}
|
||||
onClick={() => void toggleChat(participant)}
|
||||
aria-label={
|
||||
participant.chatMuted
|
||||
? `Przywróć pisanie na czacie: ${participant.name}`
|
||||
: `Wycisz na czacie: ${participant.name}`
|
||||
}
|
||||
title={participant.chatMuted ? 'Przywróć pisanie na czacie' : 'Wycisz na czacie'}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-warn-600 hover:bg-warn-50 disabled:opacity-40"
|
||||
>
|
||||
<Icon name={participant.chatMuted ? 'message-square' : 'mic-off'} size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === participant.identity}
|
||||
onClick={() => void remove(participant)}
|
||||
aria-label={`Usuń z sali: ${participant.name}`}
|
||||
title="Usuń z sali"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-alarm-600 hover:bg-alarm-50 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="user-off" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{data && data.banned.length > 0 && (
|
||||
<div className="border-t border-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBanned((value) => !value)}
|
||||
aria-expanded={showBanned}
|
||||
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-xs font-medium text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
<span>
|
||||
Zablokowani: <span className="hx-data">{data.banned.length}</span>
|
||||
</span>
|
||||
<Icon name={showBanned ? 'chevron-up' : 'chevron-down'} size={14} />
|
||||
</button>
|
||||
{showBanned && (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{data.banned.map((person) => (
|
||||
<li key={person.registrationId} className="flex items-center gap-2 px-4 py-2 text-sm">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-slate-700">{person.name}</p>
|
||||
<p className="truncate text-xs text-slate-500">{person.email}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === `ban-${person.registrationId}`}
|
||||
onClick={() => void unban(person.registrationId, person.name)}
|
||||
className="flex min-h-9 shrink-0 items-center gap-1 rounded-md px-2 text-xs font-medium text-blue-700 hover:bg-blue-50 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="user-plus" size={14} />
|
||||
Odblokuj
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="border-t border-slate-200 px-3 py-2">
|
||||
<Pagination
|
||||
page={data.page}
|
||||
totalPages={data.totalPages}
|
||||
totalElements={data.totalElements}
|
||||
pageSize={data.size}
|
||||
onChange={setPage}
|
||||
itemLabel="osób"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user