feat(frontend): admin card management with excel import/export
This commit is contained in:
47
packages/frontend/src/components/CardTable.tsx
Normal file
47
packages/frontend/src/components/CardTable.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import type { Card, CardCreateInput } from '@flashcard/shared';
|
||||
import { cardsApi } from '../api/cards.js';
|
||||
|
||||
export function CardTable({ lessonId, cards, onChange }: { lessonId: number; cards: Card[]; onChange: () => void }) {
|
||||
const [draft, setDraft] = useState<CardCreateInput>({ question: '', answer: '', hint: '' });
|
||||
|
||||
async function add() {
|
||||
if (!draft.question.trim() || !draft.answer.trim()) return;
|
||||
await cardsApi.create(lessonId, { question: draft.question.trim(), answer: draft.answer.trim(), hint: draft.hint?.trim() || null });
|
||||
setDraft({ question: '', answer: '', hint: '' });
|
||||
onChange();
|
||||
}
|
||||
async function update(c: Card, field: 'question' | 'answer' | 'hint', value: string) {
|
||||
await cardsApi.update(c.id, { [field]: value || null });
|
||||
onChange();
|
||||
}
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Verwijder kaart?')) return;
|
||||
await cardsApi.remove(id);
|
||||
onChange();
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-slate-500">
|
||||
<th className="py-2">Vraag</th><th>Antwoord</th><th>Hint</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{cards.map((c) => (
|
||||
<tr key={c.id} className="border-t border-slate-200 dark:border-slate-800">
|
||||
<td><input className="w-full bg-transparent py-1" defaultValue={c.question} onBlur={(e) => update(c, 'question', e.target.value)} /></td>
|
||||
<td><input className="w-full bg-transparent py-1" defaultValue={c.answer} onBlur={(e) => update(c, 'answer', e.target.value)} /></td>
|
||||
<td><input className="w-full bg-transparent py-1" defaultValue={c.hint ?? ''} onBlur={(e) => update(c, 'hint', e.target.value)} /></td>
|
||||
<td><button className="text-xs text-red-600" onClick={() => remove(c.id)}>x</button></td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-t border-slate-200 dark:border-slate-800">
|
||||
<td><input className="w-full bg-transparent py-1" placeholder="Nieuwe vraag" value={draft.question} onChange={(e) => setDraft({ ...draft, question: e.target.value })} /></td>
|
||||
<td><input className="w-full bg-transparent py-1" placeholder="Antwoord" value={draft.answer} onChange={(e) => setDraft({ ...draft, answer: e.target.value })} /></td>
|
||||
<td><input className="w-full bg-transparent py-1" placeholder="Hint (optioneel)" value={draft.hint ?? ''} onChange={(e) => setDraft({ ...draft, hint: e.target.value })} /></td>
|
||||
<td><button className="rounded bg-blue-600 px-2 py-1 text-xs text-white" onClick={add}>+</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
49
packages/frontend/src/components/ImportDialog.tsx
Normal file
49
packages/frontend/src/components/ImportDialog.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState } from 'react';
|
||||
import { cardsApi, type ImportResult } from '../api/cards.js';
|
||||
|
||||
export function ImportDialog({ lessonId, onClose, onDone }: { lessonId: number; onClose: () => void; onDone: () => void }) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [updateExisting, setUpdateExisting] = useState(true);
|
||||
const [createMissing, setCreateMissing] = useState(false);
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function run() {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await cardsApi.importXlsx(lessonId, file, { updateExisting, createMissingLessons: createMissing });
|
||||
setResult(r);
|
||||
onDone();
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-black/40">
|
||||
<div className="w-full max-w-md rounded bg-white p-6 dark:bg-slate-900">
|
||||
<h2 className="mb-3 text-lg font-semibold">Excel import</h2>
|
||||
<p className="mb-3 text-xs text-slate-500">Kolommen: <code>question</code>, <code>answer</code>, <code>hint</code> (optioneel), <code>lesson_path</code> (optioneel, bv. "Spaans/Begroetingen").</p>
|
||||
<input type="file" accept=".xlsx" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||
<label className="mt-3 flex items-center gap-2 text-sm"><input type="checkbox" checked={updateExisting} onChange={(e) => setUpdateExisting(e.target.checked)} /> Bestaande kaarten bijwerken</label>
|
||||
<label className="mt-1 flex items-center gap-2 text-sm"><input type="checkbox" checked={createMissing} onChange={(e) => setCreateMissing(e.target.checked)} /> Onbekende lessen aanmaken</label>
|
||||
{result && (
|
||||
<div className="mt-3 rounded bg-slate-100 p-2 text-xs dark:bg-slate-800">
|
||||
<div>Toegevoegd: {result.inserted}</div>
|
||||
<div>Bijgewerkt: {result.updated}</div>
|
||||
<div>Overgeslagen: {result.skipped}</div>
|
||||
{result.errors.length > 0 && (
|
||||
<div className="text-red-600">
|
||||
Fouten: {result.errors.length}
|
||||
<ul>{result.errors.map((e, i) => <li key={i}>rij {e.row}: {e.message}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button className="px-3 py-1" onClick={onClose}>Sluiten</button>
|
||||
<button className="rounded bg-blue-600 px-3 py-1 text-white disabled:opacity-50" onClick={run} disabled={!file || busy}>Importeer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
packages/frontend/src/pages/AdminLesson.tsx
Normal file
31
packages/frontend/src/pages/AdminLesson.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import type { Card } from '@flashcard/shared';
|
||||
import { cardsApi } from '../api/cards.js';
|
||||
import { CardTable } from '../components/CardTable.js';
|
||||
import { ImportDialog } from '../components/ImportDialog.js';
|
||||
|
||||
export function AdminLessonPage() {
|
||||
const { id } = useParams();
|
||||
const lessonId = Number(id);
|
||||
const [cards, setCards] = useState<Card[]>([]);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
|
||||
async function refresh() { setCards(await cardsApi.list(lessonId)); }
|
||||
useEffect(() => { refresh(); }, [lessonId]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl p-6">
|
||||
<Link to="/admin" className="text-sm text-blue-600">← Lessen</Link>
|
||||
<h1 className="my-3 text-2xl font-semibold">Kaarten</h1>
|
||||
<div className="mb-4 flex gap-2">
|
||||
<button className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" onClick={() => setShowImport(true)}>Importeer Excel</button>
|
||||
<a className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" href={cardsApi.exportUrl(lessonId, false)}>Exporteer Excel</a>
|
||||
<a className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" href={cardsApi.exportUrl(lessonId, true)}>Exporteer + sublessen</a>
|
||||
<Link to={`/practice/${lessonId}/setup`} className="ml-auto rounded bg-green-600 px-3 py-1 text-white">Start oefenen →</Link>
|
||||
</div>
|
||||
<CardTable lessonId={lessonId} cards={cards} onChange={refresh} />
|
||||
{showImport && <ImportDialog lessonId={lessonId} onClose={() => setShowImport(false)} onDone={refresh} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import { Layout } from './components/Layout.js';
|
||||
import { AdminPage } from './pages/Admin.js';
|
||||
import { AdminLessonPage } from './pages/AdminLesson.js';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -9,6 +10,7 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{ index: true, element: <div className="p-6">Dashboard placeholder</div> },
|
||||
{ path: 'admin', element: <AdminPage /> },
|
||||
{ path: 'admin/lessons/:id', element: <AdminLessonPage /> },
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user