Initial commit

This commit is contained in:
2026-09-02 08:21:27 +02:00
commit 84eec0039e
609 changed files with 94880 additions and 0 deletions
+840
View File
@@ -0,0 +1,840 @@
import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Quiz, QuizImportResult, QuizQuestionAdmin, QuizStats } from '../../api/types'
import {
Alert,
Badge,
Button,
Card,
EmptyState,
ErrorState,
Field,
Input,
Spinner,
Textarea,
} from '../../components/ui'
import { Icon } from '../../components/Icon'
import { Modal } from '../../components/Modal'
import { useToasts } from '../../components/Toasts'
type Tab = 'pytania' | 'ustawienia' | 'statystyki' | 'podglad'
/**
* Edytor quizu, otwierany z lekcji typu Sprawdzian.
*
* Cztery zakladki, bo to cztery rozne zadania: uklada sie pytania, ustawia
* zasady, oglada wyniki albo sprawdza, jak test wyglada oczami kursanta.
* Wszystko w jednym oknie oznaczaloby przewijanie przez kilkadziesiat pytan
* za kazdym razem, gdy trzeba poprawic limit czasu.
*/
export function QuizEditor({
courseId,
lessonId,
onClose,
}: {
courseId: number
lessonId: number
onClose: () => void
}) {
const [tab, setTab] = useState<Tab>('pytania')
const base = `/admin/courses/${courseId}/lessons/${lessonId}/quiz`
const quiz = useQuery({
queryKey: ['quiz', courseId, lessonId],
queryFn: async () => (await client.get<Quiz>(base)).data,
})
return (
<Modal title="Sprawdzian" onClose={onClose} wide>
{quiz.isPending && <Spinner />}
{quiz.isError && (
<ErrorState message={errorMessage(quiz.error, 'Nie udało się wczytać quizu')} />
)}
{quiz.data && (
<div className="space-y-4">
{quiz.data.questionsInvalid > 0 && (
<Alert tone="warn">
<strong>
{quiz.data.questionsInvalid}{' '}
{quiz.data.questionsInvalid === 1 ? 'pytanie wymaga' : 'pytania wymagają'} poprawy.
</strong>{' '}
Każde pytanie musi mieć dokładnie jedną odpowiedź poprawną i co najmniej dwie
odpowiedzi. Kursu z takim pytaniem nie da się opublikować.
</Alert>
)}
<nav role="tablist" aria-label="Sekcje quizu" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
{(
[
['pytania', 'Pytania', 'file-text', quiz.data.questionsTotal],
['ustawienia', 'Ustawienia', 'settings', null],
['statystyki', 'Statystyki', 'presentation', null],
['podglad', 'Podgląd', 'eye', null],
] as const
).map(([value, label, icon, count]) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
onClick={() => setTab(value)}
className={`flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
tab === value
? 'bg-blue-50 font-medium text-blue-700'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
}`}
>
<Icon name={icon} size={16} />
{label}
{count !== null && <span className="hx-data text-xs text-slate-400">{count}</span>}
</button>
))}
</nav>
{tab === 'pytania' && <QuestionsTab base={base} courseId={courseId} lessonId={lessonId} />}
{tab === 'ustawienia' && (
<SettingsTab base={base} courseId={courseId} lessonId={lessonId} quiz={quiz.data} />
)}
{tab === 'statystyki' && <StatsTab base={base} />}
{tab === 'podglad' && <PreviewTab base={base} quiz={quiz.data} />}
</div>
)}
</Modal>
)
}
// --- pytania ---
type AnswerDraft = { content: string; correct: boolean }
type QuestionDraft = { content: string; explanation: string; answers: AnswerDraft[]; active: boolean }
const EMPTY_DRAFT: QuestionDraft = {
content: '',
explanation: '',
// dwie puste odpowiedzi od razu: minimum wymagane regula, wiec zaczynamy od niego
answers: [
{ content: '', correct: true },
{ content: '', correct: false },
],
active: true,
}
function QuestionsTab({
base,
courseId,
lessonId,
}: {
base: string
courseId: number
lessonId: number
}) {
const queryClient = useQueryClient()
const toasts = useToasts()
const fileRef = useRef<HTMLInputElement>(null)
const [editing, setEditing] = useState<{ id: number | null; draft: QuestionDraft } | null>(null)
const [importResult, setImportResult] = useState<QuizImportResult | null>(null)
const questions = useQuery({
queryKey: ['quiz', courseId, lessonId, 'questions'],
queryFn: async () => (await client.get<QuizQuestionAdmin[]>(`${base}/questions`)).data,
})
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['quiz', courseId, lessonId] })
}
const save = useMutation({
mutationFn: ({ id, draft }: { id: number | null; draft: QuestionDraft }) => {
const body = {
content: draft.content,
explanation: draft.explanation || null,
active: draft.active,
answers: draft.answers.map((a) => ({ content: a.content, correct: a.correct })),
}
return id === null
? client.post(`${base}/questions`, body)
: client.put(`${base}/questions/${id}`, body)
},
onSuccess: () => {
setEditing(null)
invalidate()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać pytania')),
})
const remove = useMutation({
mutationFn: (id: number) => client.delete(`${base}/questions/${id}`),
onSuccess: invalidate,
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć pytania')),
})
const reorder = useMutation({
mutationFn: (ids: number[]) => client.put(`${base}/questions/order`, { ids }),
onSuccess: invalidate,
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać kolejności')),
})
const upload = useMutation({
mutationFn: async (file: File) => {
const form = new FormData()
form.append('file', file)
return (await client.post<QuizImportResult>(`${base}/questions/import`, form)).data
},
onSuccess: (result) => {
setImportResult(result)
invalidate()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wczytać pliku')),
})
/** Przesuwa pytanie o jedna pozycje. Jedyna droga dla osoby bez myszy. */
const move = (id: number, delta: number) => {
const ids = (questions.data ?? []).map((q) => q.id)
const from = ids.indexOf(id)
const to = from + delta
if (from < 0 || to < 0 || to >= ids.length) return
const next = [...ids]
next.splice(to, 0, next.splice(from, 1)[0])
reorder.mutate(next)
}
if (questions.isPending) return <Spinner />
if (questions.isError) {
return <ErrorState message={errorMessage(questions.error, 'Nie udało się wczytać pytań')} />
}
const list = questions.data ?? []
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<Button icon="plus" onClick={() => setEditing({ id: null, draft: EMPTY_DRAFT })}>
Dodaj pytanie
</Button>
<Button variant="secondary" icon="upload" onClick={() => fileRef.current?.click()}>
Import z CSV
</Button>
<input
ref={fileRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) upload.mutate(file)
e.target.value = ''
}}
/>
</div>
<details className="rounded-md border border-slate-200 p-3 text-sm">
<summary className="cursor-pointer font-medium text-slate-700">Format pliku CSV</summary>
<div className="mt-2 space-y-1 text-slate-600">
<p>Jedno pytanie w wierszu, kolumny rozdzielone średnikiem:</p>
<p className="hx-data rounded bg-slate-50 p-2 text-xs">
Treść pytania;odpowiedź;*odpowiedź poprawna;kolejna;#wyjaśnienie
</p>
<p>
Gwiazdka na początku oznacza odpowiedź poprawną, krzyżyk wyjaśnienie. Wiersz z błędem
jest pomijany, reszta pliku wczytuje się normalnie.
</p>
</div>
</details>
{importResult && (
<Alert tone={importResult.skipped > 0 ? 'warn' : 'ok'}>
<p>
Wczytano pytań: <strong>{importResult.imported}</strong>
{importResult.skipped > 0 && <>, pominięto: <strong>{importResult.skipped}</strong></>}
</p>
{importResult.problems.length > 0 && (
<ul className="mt-1 list-disc pl-5 text-xs">
{importResult.problems.slice(0, 5).map((problem) => (
<li key={problem}>{problem}</li>
))}
</ul>
)}
</Alert>
)}
{list.length === 0 && (
<EmptyState
icon="file-text"
title="Quiz nie ma jeszcze pytań"
description="Dodaj pytanie ręcznie albo wczytaj listę z pliku CSV."
/>
)}
<ul className="space-y-2">
{list.map((question, index) => (
<li
key={question.id}
className={`rounded-md border p-3 ${
question.valid ? 'border-slate-100' : 'border-warn-500/40 bg-warn-50'
}`}
>
<div className="flex flex-wrap items-start gap-2">
<span className="hx-data mt-0.5 text-xs text-slate-400">{index + 1}.</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-900">{question.content}</p>
<ul className="mt-1 space-y-0.5">
{question.answers.map((answer) => (
<li
key={answer.id}
className={`flex items-center gap-1.5 text-xs ${
answer.correct ? 'font-medium text-ok-600' : 'text-slate-500'
}`}
>
<Icon name={answer.correct ? 'check-circle' : 'x'} size={13} />
{answer.content}
</li>
))}
</ul>
{!question.valid && (
<p className="mt-1 text-xs font-medium text-warn-600">
Wymagana dokładnie jedna odpowiedź poprawna i co najmniej dwie odpowiedzi.
</p>
)}
</div>
<div className="flex items-center gap-1">
{!question.active && <Badge tone="neutral">wyłączone</Badge>}
<IconAction
icon="chevron-up"
label={`Przesuń pytanie ${index + 1} w górę`}
disabled={index === 0}
onClick={() => move(question.id, -1)}
/>
<IconAction
icon="chevron-down"
label={`Przesuń pytanie ${index + 1} w dół`}
disabled={index === list.length - 1}
onClick={() => move(question.id, 1)}
/>
<IconAction
icon="pencil"
label={`Edytuj pytanie ${index + 1}`}
onClick={() =>
setEditing({
id: question.id,
draft: {
content: question.content,
explanation: question.explanation ?? '',
active: question.active,
answers: question.answers.map((a) => ({
content: a.content,
correct: a.correct,
})),
},
})
}
/>
<IconAction
icon="trash"
label={`Usuń pytanie ${index + 1}`}
onClick={() => {
if (window.confirm('Usunąć to pytanie?')) remove.mutate(question.id)
}}
/>
</div>
</div>
</li>
))}
</ul>
{editing && (
<QuestionForm
draft={editing.draft}
pending={save.isPending}
onCancel={() => setEditing(null)}
onSave={(draft) => save.mutate({ id: editing.id, draft })}
/>
)}
</div>
)
}
/** Formularz pytania. Poprawna odpowiedz wybierana jednym przelacznikiem. */
function QuestionForm({
draft,
pending,
onCancel,
onSave,
}: {
draft: QuestionDraft
pending: boolean
onCancel: () => void
onSave: (draft: QuestionDraft) => void
}) {
const [form, setForm] = useState<QuestionDraft>(draft)
const [error, setError] = useState<string | null>(null)
const setAnswer = (index: number, content: string) =>
setForm((prev) => ({
...prev,
answers: prev.answers.map((a, i) => (i === index ? { ...a, content } : a)),
}))
/*
* Wybor poprawnej odpowiedzi zeruje pozostale: regula dopuszcza dokladnie
* jedna, wiec pola wyboru zachowuja sie jak przelacznik, a nie jak zestaw
* niezaleznych pol. Bez tego uzytkownik zaznaczylby dwie i dowiedzial sie
* o bledzie dopiero przy zapisie.
*/
const setCorrect = (index: number) =>
setForm((prev) => ({
...prev,
answers: prev.answers.map((a, i) => ({ ...a, correct: i === index })),
}))
const addAnswer = () =>
setForm((prev) => ({ ...prev, answers: [...prev.answers, { content: '', correct: false }] }))
const removeAnswer = (index: number) =>
setForm((prev) => {
const answers = prev.answers.filter((_, i) => i !== index)
// usuniecie poprawnej zostawiloby pytanie bez klucza: przenosimy na pierwsza
if (!answers.some((a) => a.correct) && answers.length > 0) {
answers[0] = { ...answers[0], correct: true }
}
return { ...prev, answers }
})
const submit = (event: React.FormEvent) => {
event.preventDefault()
setError(null)
if (!form.content.trim()) {
setError('Treść pytania jest wymagana')
return
}
const filled = form.answers.filter((a) => a.content.trim())
if (filled.length < 2) {
setError('Pytanie musi mieć co najmniej dwie odpowiedzi')
return
}
if (filled.filter((a) => a.correct).length !== 1) {
setError('Zaznacz dokładnie jedną odpowiedź poprawną')
return
}
onSave({ ...form, answers: filled })
}
return (
<form onSubmit={submit} noValidate className="space-y-3 rounded-md border border-blue-200 bg-blue-50/40 p-3">
{error && <Alert tone="alarm">{error}</Alert>}
<Field label="Treść pytania">
<Textarea
rows={2}
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
autoFocus
/>
</Field>
<div>
<p className="mb-1.5 text-sm font-semibold text-slate-800">
Odpowiedzi <span className="font-normal text-slate-500">(zaznacz poprawną)</span>
</p>
<ul className="space-y-2">
{form.answers.map((answer, index) => (
<li key={index} className="flex items-center gap-2">
<input
type="radio"
name="correct-answer"
checked={answer.correct}
onChange={() => setCorrect(index)}
aria-label={`Odpowiedź ${index + 1} jest poprawna`}
className="h-4 w-4 shrink-0 accent-ok-500"
/>
<Input
className="min-w-0 flex-1"
value={answer.content}
onChange={(e) => setAnswer(index, e.target.value)}
placeholder={`Odpowiedź ${index + 1}`}
aria-label={`Treść odpowiedzi ${index + 1}`}
/>
<IconAction
icon="trash"
label={`Usuń odpowiedź ${index + 1}`}
disabled={form.answers.length <= 2}
onClick={() => removeAnswer(index)}
/>
</li>
))}
</ul>
<Button type="button" variant="ghost" icon="plus" onClick={addAnswer} className="mt-1">
Dodaj odpowiedź
</Button>
</div>
<Field label="Wyjaśnienie" hint="Pokazywane po zakończeniu, gdy quiz pokazuje odpowiedzi.">
<Textarea
rows={2}
value={form.explanation}
onChange={(e) => setForm({ ...form, explanation: e.target.value })}
/>
</Field>
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={form.active}
onChange={(e) => setForm({ ...form, active: e.target.checked })}
className="h-4 w-4 accent-blue-500"
/>
Pytanie aktywne, bierze udział w losowaniu
</label>
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="secondary" onClick={onCancel}>
Anuluj
</Button>
<Button type="submit" icon="save" disabled={pending}>
{pending ? 'Zapisywanie...' : 'Zapisz pytanie'}
</Button>
</div>
</form>
)
}
// --- ustawienia ---
function SettingsTab({
base,
courseId,
lessonId,
quiz,
}: {
base: string
courseId: number
lessonId: number
quiz: Quiz
}) {
const queryClient = useQueryClient()
const toasts = useToasts()
const [form, setForm] = useState({
title: quiz.title,
description: quiz.description ?? '',
questionCount: quiz.questionCount === null ? '' : String(quiz.questionCount),
passThresholdPercent:
quiz.passThresholdPercent === null ? '' : String(quiz.passThresholdPercent),
timeLimitMinutes: quiz.timeLimitMinutes === null ? '' : String(quiz.timeLimitMinutes),
maxAttempts: quiz.maxAttempts === null ? '' : String(quiz.maxAttempts),
retryDelayMinutes: quiz.retryDelayMinutes === null ? '' : String(quiz.retryDelayMinutes),
shuffleQuestions: quiz.shuffleQuestions,
shuffleAnswers: quiz.shuffleAnswers,
showCorrectAnswers: quiz.showCorrectAnswers,
})
const save = useMutation({
mutationFn: () =>
client.put(base, {
title: form.title,
description: form.description || null,
questionCount: form.questionCount === '' ? null : Number(form.questionCount),
passThresholdPercent:
form.passThresholdPercent === '' ? null : Number(form.passThresholdPercent),
timeLimitMinutes: form.timeLimitMinutes === '' ? null : Number(form.timeLimitMinutes),
maxAttempts: form.maxAttempts === '' ? null : Number(form.maxAttempts),
retryDelayMinutes: form.retryDelayMinutes === '' ? null : Number(form.retryDelayMinutes),
shuffleQuestions: form.shuffleQuestions,
shuffleAnswers: form.shuffleAnswers,
showCorrectAnswers: form.showCorrectAnswers,
}),
onSuccess: () => {
toasts.success('Ustawienia zapisane')
queryClient.invalidateQueries({ queryKey: ['quiz', courseId, lessonId] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać ustawień')),
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
save.mutate()
}}
noValidate
className="space-y-4"
>
<Field label="Tytuł sprawdzianu">
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
</Field>
<Field label="Opis" hint="Pokazywany kursantowi przed rozpoczęciem.">
<Textarea
rows={2}
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field
label="Ile pytań losować"
hint={`Puste = wszystkie aktywne (teraz ${quiz.questionsActive}).`}
>
<Input
type="number"
min={1}
value={form.questionCount}
onChange={(e) => setForm({ ...form, questionCount: e.target.value })}
placeholder="wszystkie"
/>
</Field>
<Field
label="Próg zaliczenia (%)"
hint={`Puste = próg kursu, czyli ${quiz.effectiveThreshold}%.`}
>
<Input
type="number"
min={0}
max={100}
value={form.passThresholdPercent}
onChange={(e) => setForm({ ...form, passThresholdPercent: e.target.value })}
placeholder={`${quiz.effectiveThreshold}`}
/>
</Field>
<Field label="Limit czasu (minuty)" hint="Puste = bez limitu. Pilnuje go serwer.">
<Input
type="number"
min={1}
value={form.timeLimitMinutes}
onChange={(e) => setForm({ ...form, timeLimitMinutes: e.target.value })}
placeholder="bez limitu"
/>
</Field>
<Field label="Liczba podejść" hint="Puste = bez limitu.">
<Input
type="number"
min={1}
value={form.maxAttempts}
onChange={(e) => setForm({ ...form, maxAttempts: e.target.value })}
placeholder="bez limitu"
/>
</Field>
<Field
label="Odstęp między podejściami (minuty)"
hint="Liczony od zakończenia poprzedniego podejścia."
>
<Input
type="number"
min={0}
value={form.retryDelayMinutes}
onChange={(e) => setForm({ ...form, retryDelayMinutes: e.target.value })}
placeholder="brak"
/>
</Field>
</div>
<div className="space-y-2">
<Toggle
checked={form.shuffleQuestions}
onChange={(v) => setForm({ ...form, shuffleQuestions: v })}
title="Losuj kolejność pytań"
description="Zestaw jest losowany raz, przy starcie podejścia, i nie zmienia się po odświeżeniu."
/>
<Toggle
checked={form.shuffleAnswers}
onChange={(v) => setForm({ ...form, shuffleAnswers: v })}
title="Losuj kolejność odpowiedzi"
description="Utrudnia przepisywanie odpowiedzi od innego kursanta."
/>
<Toggle
checked={form.showCorrectAnswers}
onChange={(v) => setForm({ ...form, showCorrectAnswers: v })}
title="Pokaż poprawne odpowiedzi po zakończeniu"
description="Przy quizie z wieloma podejściami warto wyłączyć: inaczej pierwsze podejście służy do zebrania klucza."
/>
</div>
<div className="flex justify-end">
<Button type="submit" icon="save" disabled={save.isPending}>
{save.isPending ? 'Zapisywanie...' : 'Zapisz ustawienia'}
</Button>
</div>
</form>
)
}
// --- statystyki ---
function StatsTab({ base }: { base: string }) {
const stats = useQuery({
queryKey: [base, 'stats'],
queryFn: async () => (await client.get<QuizStats>(`${base}/stats`)).data,
})
if (stats.isPending) return <Spinner />
if (stats.isError) {
return <ErrorState message={errorMessage(stats.error, 'Nie udało się wczytać statystyk')} />
}
const data = stats.data!
if (data.finishedAttempts === 0) {
return (
<EmptyState
icon="presentation"
title="Nikt jeszcze nie rozwiązał sprawdzianu"
description="Statystyki pojawią się po pierwszym zakończonym podejściu."
/>
)
}
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<Stat label="Podejścia" value={String(data.attempts)} />
<Stat label="Średni wynik" value={`${data.averageScore}%`} />
<Stat label="Zdawalność" value={`${data.passRate}%`} />
</div>
<Card title="Pytania najczęściej mylone" description="Najniższa skuteczność na górze.">
<ul className="space-y-2">
{data.questions.map((question) => (
<li key={question.questionId} className="rounded-md border border-slate-100 p-2.5">
<p className="text-sm text-slate-900">{question.content}</p>
<div className="mt-1.5 flex items-center gap-2">
<div className="h-2 w-32 overflow-hidden rounded-full bg-slate-100">
<div
className={`h-full rounded-full ${
question.correctRate < 50 ? 'bg-alarm-500' : 'bg-ok-500'
}`}
style={{ width: `${question.correctRate}%` }}
/>
</div>
<span className="hx-data text-xs text-slate-500">
{question.correctRate}% · {question.correct}/{question.answered}
</span>
</div>
</li>
))}
</ul>
</Card>
</div>
)
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-slate-100 bg-white p-3">
<p className="hx-data text-xl font-semibold text-slate-900">{value}</p>
<p className="text-xs text-slate-500">{label}</p>
</div>
)
}
// --- podglad ---
/**
* Podglad oczami kursanta.
*
* Pokazuje pytania **bez zaznaczonej poprawnej odpowiedzi**, czyli dokladnie
* tak, jak zobaczy je kursant. Sluzy do sprawdzenia, czy tresc jest zrozumiala
* bez klucza: pytanie oczywiste dla autora bywa niejasne dla odbiorcy.
*/
function PreviewTab({ base, quiz }: { base: string; quiz: Quiz }) {
const questions = useQuery({
queryKey: [base, 'questions', 'preview'],
queryFn: async () => (await client.get<QuizQuestionAdmin[]>(`${base}/questions`)).data,
})
if (questions.isPending) return <Spinner />
const active = (questions.data ?? []).filter((q) => q.active)
return (
<div className="space-y-3">
<Alert tone="info">
Tak zobaczy sprawdzian kursant: bez zaznaczonych odpowiedzi poprawnych. Losowana jest{' '}
<strong>{quiz.questionCount ?? quiz.questionsActive}</strong> z {active.length} aktywnych
pytań, próg zaliczenia to <strong>{quiz.effectiveThreshold}%</strong>.
</Alert>
{active.length === 0 && (
<EmptyState icon="file-text" title="Brak aktywnych pytań" description="Dodaj pytania." />
)}
<ol className="space-y-3">
{active.map((question, index) => (
<li key={question.id} className="rounded-md border border-slate-100 p-3">
<p className="text-sm font-medium text-slate-900">
{index + 1}. {question.content}
</p>
<ul className="mt-2 space-y-1.5">
{question.answers.map((answer) => (
<li key={answer.id} className="flex items-center gap-2 text-sm text-slate-700">
<span className="h-4 w-4 shrink-0 rounded-full border-2 border-slate-300" />
{answer.content}
</li>
))}
</ul>
</li>
))}
</ol>
</div>
)
}
// --- wspolne ---
function Toggle({
checked,
onChange,
title,
description,
}: {
checked: boolean
onChange: (value: boolean) => void
title: string
description: string
}) {
return (
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-200 p-3 transition-colors duration-fast ease-standard hover:bg-slate-25">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
/>
<span className="min-w-0 text-sm leading-relaxed text-slate-700">
<strong className="text-slate-900">{title}</strong>
<span className="mt-0.5 block text-xs text-slate-500">{description}</span>
</span>
</label>
)
}
function IconAction({
icon,
label,
onClick,
disabled = false,
}: {
icon: Parameters<typeof Icon>[0]['name']
label: string
onClick: () => void
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={label}
aria-label={label}
className="flex h-11 w-11 items-center justify-center rounded-md text-slate-500 transition duration-fast ease-standard hover:bg-slate-50 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-200 disabled:hover:bg-transparent"
>
<Icon name={icon} size={16} />
</button>
)
}