Moderacja wyrazow: slownik i wstrzymywanie wiadomosci do akceptacji
Deploy PROD / deploy (push) Successful in 33s
Deploy PROD / deploy (push) Successful in 33s
Wiadomosc czatu i pytanie uczestnika przechodza przez slownik przed publikacja. Trafienie wstrzymuje je (status NEW, moderation_hit), widzi je tylko panel prowadzenia z przyciskami Opublikuj / Odrzuc, a autor dostaje informacje, ze wiadomosc czeka. Wiadomosci prowadzacych nie sa filtrowane. Normalizacja rozpoznaje ogonki, wielkie litery, cyfry zamiast liter, powtorzenia i rozstrzelenia. Slownik startowy w V31: wulgaryzmy, obelgi, tresci zwiazane z pedofilia. WordFilterTest czyta slownik z migracji i sprawdza obie strony, takze slownictwo wodociagowe. Ekran "Moderacja wyrazow" w menu: lista z wyszukiwaniem i kategoriami, dodawanie, edycja, wylaczanie, usuwanie z potwierdzeniem, sprawdzanie tekstu. Dostep ADMIN i MODERATOR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,7 @@ import { HomePageSettingsPage } from './pages/settings/HomePageSettingsPage'
|
||||
import { UserListPage } from './pages/users/UserListPage'
|
||||
import { ApiKeyListPage } from './pages/apikeys/ApiKeyListPage'
|
||||
import { ResourcesPage } from './pages/resources/ResourcesPage'
|
||||
import { ModerationWordsPage } from './pages/moderation/ModerationWordsPage'
|
||||
import {
|
||||
AcademyChoicePage,
|
||||
AcademyPasswordResetPage,
|
||||
@@ -340,6 +341,12 @@ export function App() {
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
{/*
|
||||
Slownik moderacji: ADMIN i MODERATOR, tak jak backend (/admin/**).
|
||||
Prowadzacy dopisuje slowo zauwazone w trakcie spotkania bez szukania
|
||||
administratora.
|
||||
*/}
|
||||
<Route path="moderacja-wyrazow" element={<ModerationWordsPage />} />
|
||||
{/*
|
||||
Zasoby pokazuja stan zadan prowadzonych w tle i pozwalaja je zatrzymac,
|
||||
wiec dotycza instancji jako calosci, tak samo jak ustawienia. Backend
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
Search,
|
||||
Send,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
Upload,
|
||||
UserPlus,
|
||||
@@ -117,6 +118,7 @@ const ICONS = {
|
||||
search: Search,
|
||||
send: Send,
|
||||
settings: Settings,
|
||||
'shield-alert': ShieldAlert,
|
||||
trash: Trash2,
|
||||
upload: Upload,
|
||||
'user-plus': UserPlus,
|
||||
|
||||
@@ -104,6 +104,8 @@ export const NAV_AREAS: NavArea[] = [
|
||||
export const NAV_COMMON: NavItem[] = [
|
||||
{ to: panelPath('/kontakty'), label: 'Kontakty', icon: 'users' },
|
||||
{ to: panelPath('/banery'), label: 'Banery', icon: 'image', roles: ['ADMIN'] },
|
||||
// bez ograniczenia roli: prowadzacy (MODERATOR) ma dopisac slowo zauwazone w trakcie spotkania
|
||||
{ to: panelPath('/moderacja-wyrazow'), label: 'Moderacja wyrazów', icon: 'shield-alert' },
|
||||
{ to: panelPath('/ustawienia'), label: 'Ustawienia', icon: 'settings', roles: ['ADMIN'] },
|
||||
{ to: panelPath('/uzytkownicy'), label: 'Użytkownicy', icon: 'users', roles: ['ADMIN'] },
|
||||
{ to: panelPath('/klucze-api'), label: 'Klucze API', icon: 'key', roles: ['ADMIN'] },
|
||||
|
||||
@@ -60,6 +60,8 @@ export type ModeratorInteractionView = {
|
||||
authorChatMuted: boolean
|
||||
authorRoomBanned: boolean
|
||||
createdAt: string
|
||||
/** Slowo ze slownika moderacji, ktore wstrzymalo wiadomosc; null bez trafienia. */
|
||||
moderationHit: string | null
|
||||
}
|
||||
|
||||
type History = {
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage, fieldErrors } 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 {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
Spinner,
|
||||
Textarea,
|
||||
} from '../../components/ui'
|
||||
|
||||
type Category = 'VULGAR' | 'INSULT' | 'CHILD_ABUSE'
|
||||
type MatchMode = 'WORD' | 'PREFIX' | 'CONTAINS'
|
||||
|
||||
type Word = {
|
||||
id: number
|
||||
phrase: string
|
||||
category: Category
|
||||
matchMode: MatchMode
|
||||
active: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type Page<T> = {
|
||||
content: T[]
|
||||
totalElements: number
|
||||
totalPages: number
|
||||
number: number
|
||||
}
|
||||
|
||||
type CheckResult = { held: boolean; phrase: string | null; category: Category | null }
|
||||
|
||||
const CATEGORY_LABEL: Record<Category, string> = {
|
||||
VULGAR: 'Wulgaryzm',
|
||||
INSULT: 'Obelga',
|
||||
CHILD_ABUSE: 'Pedofilia i wykorzystywanie dzieci',
|
||||
}
|
||||
|
||||
const CATEGORY_TONE: Record<Category, 'warn' | 'info' | 'alarm'> = {
|
||||
VULGAR: 'warn',
|
||||
INSULT: 'info',
|
||||
CHILD_ABUSE: 'alarm',
|
||||
}
|
||||
|
||||
const MODE_LABEL: Record<MatchMode, string> = {
|
||||
WORD: 'Całe słowo',
|
||||
PREFIX: 'Początek słowa (wszystkie odmiany)',
|
||||
CONTAINS: 'Fragment słowa (także w złożeniach)',
|
||||
}
|
||||
|
||||
/** Skutek wpisu zwyklym zdaniem, zgodnie z zasada przyjaznego interfejsu. */
|
||||
function describeEffect(phrase: string, mode: MatchMode): string {
|
||||
const p = phrase.trim()
|
||||
if (!p) return 'Wpisz słowo, a zobaczysz, jakie wiadomości zostaną zatrzymane.'
|
||||
if (/\s/.test(p)) return `Zatrzyma wiadomości zawierające frazę „${p}”.`
|
||||
switch (mode) {
|
||||
case 'WORD':
|
||||
return `Zatrzyma wiadomości ze słowem „${p}”, ale nie z dłuższymi słowami, które się od niego zaczynają.`
|
||||
case 'PREFIX':
|
||||
return `Zatrzyma wiadomości ze słowami zaczynającymi się od „${p}”, czyli także jego odmiany.`
|
||||
case 'CONTAINS':
|
||||
return `Zatrzyma wiadomości ze słowami zawierającymi „${p}” w dowolnym miejscu, także w złożeniach.`
|
||||
}
|
||||
}
|
||||
|
||||
type FormState = { phrase: string; category: Category; matchMode: MatchMode; active: boolean }
|
||||
|
||||
const EMPTY_FORM: FormState = { phrase: '', category: 'VULGAR', matchMode: 'PREFIX', active: true }
|
||||
|
||||
/**
|
||||
* Slownik moderacji czatu i pytan.
|
||||
*
|
||||
* Wpis nie usuwa wiadomosci, tylko WSTRZYMUJE ja do decyzji prowadzacego:
|
||||
* lista slow nie rozumie kontekstu, wiec falszywe trafienie ma kosztowac
|
||||
* jedno klikniecie, a nie utracona wypowiedz. Dlatego skutek kazdego wpisu jest
|
||||
* opisany zdaniem, a pole "Sprawdz tekst" pozwala zobaczyc dzialanie filtru
|
||||
* przed webinarem, a nie w jego trakcie.
|
||||
*/
|
||||
export function ModerationWordsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [category, setCategory] = useState<Category | ''>('')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM)
|
||||
const [editing, setEditing] = useState<Word | null>(null)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const [sample, setSample] = useState('')
|
||||
const [checkResult, setCheckResult] = useState<CheckResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setQuery(search.trim())
|
||||
setPage(0)
|
||||
}, 300)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [search])
|
||||
|
||||
const words = useQuery({
|
||||
queryKey: ['moderation-words', query, category, page],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await client.get<Page<Word>>('/admin/moderation-words', {
|
||||
params: { q: query || undefined, category: category || undefined, page, size: PAGE_SIZE },
|
||||
})
|
||||
).data,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['moderation-words'] })
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (value: FormState) =>
|
||||
editing
|
||||
? client.put(`/admin/moderation-words/${editing.id}`, value)
|
||||
: client.post('/admin/moderation-words', value),
|
||||
onSuccess: () => {
|
||||
toasts.success(editing ? 'Wpis zapisany' : 'Wpis dodany')
|
||||
setForm(EMPTY_FORM)
|
||||
setEditing(null)
|
||||
setErrors({})
|
||||
setCheckResult(null)
|
||||
void refresh()
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrors(fieldErrors(error))
|
||||
toasts.error(errorMessage(error, 'Nie udało się zapisać wpisu'))
|
||||
},
|
||||
})
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: (word: Word) =>
|
||||
client.put(`/admin/moderation-words/${word.id}`, {
|
||||
phrase: word.phrase,
|
||||
category: word.category,
|
||||
matchMode: word.matchMode,
|
||||
active: !word.active,
|
||||
}),
|
||||
onSuccess: (_, word) => {
|
||||
toasts.success(word.active ? `Wyłączono: „${word.phrase}”` : `Włączono: „${word.phrase}”`)
|
||||
void refresh()
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić wpisu')),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => client.delete(`/admin/moderation-words/${id}`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Wpis usunięty')
|
||||
void refresh()
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć wpisu')),
|
||||
})
|
||||
|
||||
const check = useMutation({
|
||||
mutationFn: async (text: string) =>
|
||||
(await client.post<CheckResult>('/admin/moderation-words/check', { text })).data,
|
||||
onSuccess: (data) => setCheckResult(data),
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się sprawdzić tekstu')),
|
||||
})
|
||||
|
||||
function startEdit(word: Word) {
|
||||
setEditing(word)
|
||||
setErrors({})
|
||||
setForm({
|
||||
phrase: word.phrase,
|
||||
category: word.category,
|
||||
matchMode: word.matchMode,
|
||||
active: word.active,
|
||||
})
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditing(null)
|
||||
setErrors({})
|
||||
setForm(EMPTY_FORM)
|
||||
}
|
||||
|
||||
async function handleDelete(word: Word) {
|
||||
const ok = await confirm({
|
||||
title: `Usunąć wpis „${word.phrase}”?`,
|
||||
message:
|
||||
'Wiadomości z tym słowem przestaną być wstrzymywane. Jeśli chcesz tylko na chwilę '
|
||||
+ 'przestać go sprawdzać, wyłącz wpis zamiast go usuwać.',
|
||||
confirmLabel: 'Usuń wpis',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate(word.id)
|
||||
}
|
||||
|
||||
const data = words.data
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Moderacja wyrazów</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Wiadomość na czacie albo pytanie uczestnika, które zawiera słowo z tej listy, nie trafia do
|
||||
sali. Czeka w panelu prowadzenia, a prowadzący ją publikuje albo odrzuca.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title={editing ? `Edycja wpisu „${editing.phrase}”` : 'Dodaj wpis'}
|
||||
description="Wpisz słowo w zwykłej pisowni. Wielkość liter, polskie znaki, cyfry zamiast liter (np. 3 zamiast e) i rozstrzelenia (np. k.u.r.w.a) są rozpoznawane automatycznie."
|
||||
>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
save.mutate(form)
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Słowo albo fraza" error={errors.phrase}>
|
||||
<Input
|
||||
value={form.phrase}
|
||||
maxLength={100}
|
||||
onChange={(event) => setForm({ ...form, phrase: event.target.value })}
|
||||
placeholder="np. debil"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kategoria" error={errors.category}>
|
||||
<Select
|
||||
value={form.category}
|
||||
onChange={(event) => setForm({ ...form, category: event.target.value as Category })}
|
||||
>
|
||||
{(Object.keys(CATEGORY_LABEL) as Category[]).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{CATEGORY_LABEL[value]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Sposób dopasowania" error={errors.matchMode}>
|
||||
<Select
|
||||
value={form.matchMode}
|
||||
onChange={(event) => setForm({ ...form, matchMode: event.target.value as MatchMode })}
|
||||
>
|
||||
{(Object.keys(MODE_LABEL) as MatchMode[]).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{MODE_LABEL[value]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Stan">
|
||||
<label className="flex min-h-[44px] items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.active}
|
||||
onChange={(event) => setForm({ ...form, active: event.target.checked })}
|
||||
className="h-5 w-5 rounded border-slate-300"
|
||||
/>
|
||||
Wpis włączony
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-600">{describeEffect(form.phrase, form.matchMode)}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" icon={editing ? 'save' : 'plus'} disabled={save.isPending}>
|
||||
{editing ? 'Zapisz zmiany' : 'Dodaj wpis'}
|
||||
</Button>
|
||||
{editing && (
|
||||
<Button type="button" variant="secondary" onClick={cancelEdit}>
|
||||
Anuluj
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Sprawdź tekst"
|
||||
description="Wklej przykładową wiadomość, żeby zobaczyć, czy filtr ją zatrzyma. Nic nie jest zapisywane ani wysyłane."
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
check.mutate(sample)
|
||||
}}
|
||||
>
|
||||
<Field label="Treść wiadomości" wide>
|
||||
<Textarea
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
value={sample}
|
||||
onChange={(event) => {
|
||||
setSample(event.target.value)
|
||||
setCheckResult(null)
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" variant="secondary" icon="search" disabled={check.isPending || !sample.trim()}>
|
||||
Sprawdź
|
||||
</Button>
|
||||
{checkResult && (
|
||||
checkResult.held ? (
|
||||
<Alert tone="warn" icon="alert-triangle">
|
||||
Wiadomość zostanie wstrzymana do akceptacji prowadzącego. Trafienie: „{checkResult.phrase}”
|
||||
{checkResult.category && ` (${CATEGORY_LABEL[checkResult.category].toLowerCase()})`}.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert tone="ok" icon="check-circle">
|
||||
Wiadomość przejdzie od razu, żaden wpis jej nie zatrzymuje.
|
||||
</Alert>
|
||||
)
|
||||
)}
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card title="Lista wpisów">
|
||||
<div className="mb-3 grid gap-2 sm:grid-cols-[1fr_auto]">
|
||||
<div className="relative">
|
||||
<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 wpisu"
|
||||
aria-label="Szukaj wpisu"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={category}
|
||||
aria-label="Kategoria"
|
||||
onChange={(event) => {
|
||||
setCategory(event.target.value as Category | '')
|
||||
setPage(0)
|
||||
}}
|
||||
>
|
||||
<option value="">Wszystkie kategorie</option>
|
||||
{(Object.keys(CATEGORY_LABEL) as Category[]).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{CATEGORY_LABEL[value]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{words.isPending && <Spinner />}
|
||||
{words.isError && (
|
||||
<ErrorState message={errorMessage(words.error, 'Nie udało się wczytać słownika')} />
|
||||
)}
|
||||
{data && data.content.length === 0 && (
|
||||
<EmptyState
|
||||
icon="shield-alert"
|
||||
title={query || category ? 'Brak wpisów spełniających warunki' : 'Słownik jest pusty'}
|
||||
description={
|
||||
query || category
|
||||
? 'Zmień wyszukiwanie albo kategorię.'
|
||||
: 'Dodaj pierwszy wpis w formularzu powyżej.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data && data.content.length > 0 && (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{data.content.map((word) => (
|
||||
<li key={word.id} className="flex flex-wrap items-center gap-2 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
className={`hx-data text-sm font-semibold ${word.active ? 'text-slate-900' : 'text-slate-400 line-through'}`}
|
||||
>
|
||||
{word.phrase}
|
||||
</span>
|
||||
<Badge tone={CATEGORY_TONE[word.category]}>{CATEGORY_LABEL[word.category]}</Badge>
|
||||
{!word.active && <Badge tone="neutral">wyłączony</Badge>}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">{MODE_LABEL[word.matchMode]}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<IconButton
|
||||
icon={word.active ? 'eye-off' : 'eye'}
|
||||
label={word.active ? `Wyłącz wpis „${word.phrase}”` : `Włącz wpis „${word.phrase}”`}
|
||||
onClick={() => toggle.mutate(word)}
|
||||
disabled={toggle.isPending}
|
||||
/>
|
||||
<IconButton
|
||||
icon="pencil"
|
||||
label={`Edytuj wpis „${word.phrase}”`}
|
||||
onClick={() => startEdit(word)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
label={`Usuń wpis „${word.phrase}”`}
|
||||
onClick={() => void handleDelete(word)}
|
||||
disabled={remove.isPending}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<div className="mt-3">
|
||||
<Pagination
|
||||
page={data.number}
|
||||
totalPages={data.totalPages}
|
||||
totalElements={data.totalElements}
|
||||
onChange={setPage}
|
||||
itemLabel="wpisów"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { SpeakQueue, type SpeakRequestView } from './SpeakQueue'
|
||||
import { RoomParticipants } from './RoomParticipants'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { panelPath } from '../../lib/routes'
|
||||
import { plural } from '../../lib/dates'
|
||||
|
||||
/**
|
||||
* Panel prowadzacego: czat na zywo i kolejka pytan.
|
||||
@@ -267,9 +268,18 @@ export function HostConsolePage() {
|
||||
setChat((current) => current.filter((c) => c.id !== item.id))
|
||||
return
|
||||
}
|
||||
/*
|
||||
* Istniejaca pozycja jest PODMIENIANA, a nie pomijana: wiadomosc
|
||||
* wstrzymana przez slownik wraca tu jako APPROVED po opublikowaniu
|
||||
* i musi zgubic oznaczenie "wstrzymana".
|
||||
*/
|
||||
setChat((current) =>
|
||||
current.some((c) => c.id === item.id) ? current : [...current, item])
|
||||
setNewChat((n) => n + 1)
|
||||
current.some((c) => c.id === item.id)
|
||||
? current.map((c) => (c.id === item.id ? item : c))
|
||||
: [...current, item])
|
||||
if (event.kind !== 'APPROVED') setNewChat((n) => n + 1)
|
||||
// wstrzymana wiadomosc czeka na decyzje, wiec prowadzacy ma ja uslyszec
|
||||
if (event.kind === 'CHAT_HELD' && soundRef.current) playChime()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -355,6 +365,7 @@ export function HostConsolePage() {
|
||||
}
|
||||
|
||||
const pending = questions.filter((q) => q.status === 'NEW')
|
||||
const heldChat = chat.filter((c) => c.status === 'NEW').length
|
||||
const published = questions
|
||||
.filter((q) => q.status === 'APPROVED' || q.status === 'ANSWERED')
|
||||
.sort((a, b) => {
|
||||
@@ -428,6 +439,11 @@ export function HostConsolePage() {
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-slate-900">Czat na żywo</h2>
|
||||
<span className="hx-data text-xs text-slate-400">
|
||||
{heldChat > 0 && (
|
||||
<span className="mr-2 font-sans font-medium text-warn-600">
|
||||
{heldChat} {plural(heldChat, 'wstrzymana', 'wstrzymane', 'wstrzymanych')}
|
||||
</span>
|
||||
)}
|
||||
{chat.length}
|
||||
{newChat > 0 && <span className="ml-2 text-blue-700">+{newChat}</span>}
|
||||
</span>
|
||||
@@ -435,6 +451,7 @@ export function HostConsolePage() {
|
||||
|
||||
<ChatColumn
|
||||
messages={chat}
|
||||
onApprove={(itemId) => act(itemId, 'approve')}
|
||||
onReject={(itemId) => act(itemId, 'reject')}
|
||||
onModerate={moderateParticipant}
|
||||
/>
|
||||
@@ -472,10 +489,12 @@ export function HostConsolePage() {
|
||||
|
||||
function ChatColumn({
|
||||
messages,
|
||||
onApprove,
|
||||
onReject,
|
||||
onModerate,
|
||||
}: {
|
||||
messages: ModeratorInteractionView[]
|
||||
onApprove: (id: number) => void
|
||||
onReject: (id: number) => void
|
||||
onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean, authorName?: string) => void
|
||||
}) {
|
||||
@@ -489,8 +508,18 @@ function ChatColumn({
|
||||
{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">
|
||||
{messages.map((message) => {
|
||||
/*
|
||||
* Wiadomosc wstrzymana przez slownik moderacji. Widzi ja tylko panel;
|
||||
* decyzja jest na wierzchu, a nie pod najechaniem myszy, bo bez niej
|
||||
* wiadomosc nigdy nie trafi na sale.
|
||||
*/
|
||||
const held = message.status === 'NEW'
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`group flex items-start gap-2 text-sm ${held ? 'rounded-md border border-warn-500/30 bg-warn-50 p-2' : ''}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span
|
||||
className={`font-medium ${message.fromSpeaker ? 'text-blue-700' : 'text-slate-700'}`}
|
||||
@@ -508,6 +537,23 @@ function ChatColumn({
|
||||
</span>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap break-words text-slate-600">{message.content}</p>
|
||||
{held && (
|
||||
<>
|
||||
<p className="mt-1 text-xs text-warn-600">
|
||||
Wstrzymana przez filtr słów
|
||||
{message.moderationHit && <>: „{message.moderationHit}”</>}. Nikt poza prowadzącymi jej
|
||||
nie widzi.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button size="sm" icon="check" onClick={() => onApprove(message.id)}>
|
||||
Opublikuj
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" icon="x" onClick={() => onReject(message.id)}>
|
||||
Odrzuć
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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) */}
|
||||
@@ -553,7 +599,8 @@ function ChatColumn({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)
|
||||
@@ -581,6 +628,11 @@ function QuestionsColumn({
|
||||
{question.content}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{question.authorName}</p>
|
||||
{question.moderationHit && (
|
||||
<p className="mt-0.5 text-xs text-warn-600">
|
||||
Wstrzymane przez filtr słów: „{question.moderationHit}”
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button size="sm" icon="check" onClick={() => onAct(question.id, 'approve')}>
|
||||
Zatwierdź
|
||||
|
||||
@@ -479,6 +479,12 @@ function ChatHistoryTab({ chat, webinar }: { chat: InteractionView[]; webinar: W
|
||||
<span className="font-medium text-slate-900">{message.authorName}</span>
|
||||
{message.fromSpeaker && <Badge tone="brand">prowadzący</Badge>}
|
||||
{removed && <Badge tone="alarm">usunięta przez moderatora</Badge>}
|
||||
{/* wstrzymana przez slownik i nieopublikowana: uczestnicy jej nie widzieli */}
|
||||
{message.status === 'NEW' && (
|
||||
<Badge tone="warn">
|
||||
wstrzymana przez filtr{'moderationHit' in message && message.moderationHit ? `: „${message.moderationHit}”` : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="hx-data whitespace-nowrap text-xs text-slate-400">
|
||||
{date} {time}
|
||||
|
||||
Reference in New Issue
Block a user