feat: SMW-Importer Frontend (Phase 2 complete)
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / build-frontend (push) Waiting to run
Test Suite / playwright-tests (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled

Phase 2A: API Functions
- 5 MediaWiki import functions in api.js
- previewMediaWikiImport, executeMediaWikiImport
- getMediaWikiImportStatus, listMediaWikiImportLogs
- deleteMediaWikiImportReference

Phase 2B: UI Component
- MediaWikiImportPage.jsx (3-tab interface)
- Preview Tab: Category selection, preview with accordions
- Execute Tab: Import form with status polling
- History Tab: Import log list with refresh

Phase 2C: Routing
- Added /admin/mediawiki-import route in App.jsx
- Import and ProtectedRoute wrapper

Issue: SMW-Importer Frontend (Option C from handover)
Related: backend/routers/csv_import.py (MediaWiki endpoints)
This commit is contained in:
Lars 2026-04-24 16:06:49 +02:00
parent a67cc5f812
commit 46d000d6b3
3 changed files with 561 additions and 1 deletions

View File

@ -13,6 +13,7 @@ import TrainingPlanningPage from './pages/TrainingPlanningPage'
import AdminCatalogsPage from './pages/AdminCatalogsPage'
import AdminHierarchyPage from './pages/AdminHierarchyPage'
import TrainerContextsPage from './pages/TrainerContextsPage'
import MediaWikiImportPage from './pages/MediaWikiImportPage'
import './app.css'
// Bottom Navigation (Mobile)
@ -199,6 +200,14 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/admin/mediawiki-import"
element={
<ProtectedRoute>
<MediaWikiImportPage />
</ProtectedRoute>
}
/>
<Route
path="/trainer-contexts"
element={

View File

@ -0,0 +1,532 @@
import React, { useState, useEffect } from 'react'
import api from '../utils/api'
export default function MediaWikiImportPage() {
const [activeTab, setActiveTab] = useState('preview')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
// Preview Tab State
const [previewCategory, setPreviewCategory] = useState('Übungen')
const [previewType, setPreviewType] = useState('exercise')
const [previewLimit, setPreviewLimit] = useState(10)
const [previewData, setPreviewData] = useState(null)
// Execute Tab State
const [executeCategory, setExecuteCategory] = useState('Übungen')
const [executeType, setExecuteType] = useState('exercise')
const [executeReimport, setExecuteReimport] = useState(false)
const [executeDryRun, setExecuteDryRun] = useState(false)
const [executeLimit, setExecuteLimit] = useState(null)
const [currentImport, setCurrentImport] = useState(null)
const [pollingInterval, setPollingInterval] = useState(null)
// History Tab State
const [logs, setLogs] = useState([])
// Load logs on mount and tab switch
useEffect(() => {
if (activeTab === 'history') {
loadLogs()
}
}, [activeTab])
// Polling cleanup
useEffect(() => {
return () => {
if (pollingInterval) {
clearInterval(pollingInterval)
}
}
}, [pollingInterval])
const loadLogs = async () => {
try {
setLoading(true)
const data = await api.listMediaWikiImportLogs()
setLogs(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handlePreview = async () => {
try {
setLoading(true)
setError(null)
const data = await api.previewMediaWikiImport(previewCategory, previewType, previewLimit)
setPreviewData(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleExecute = async () => {
try {
setLoading(true)
setError(null)
const result = await api.executeMediaWikiImport({
category: executeCategory,
import_type: executeType,
reimport_existing: executeReimport,
dry_run: executeDryRun,
limit: executeLimit || null
})
setCurrentImport(result)
// Start polling
const interval = setInterval(async () => {
try {
const status = await api.getMediaWikiImportStatus(result.log_id)
setCurrentImport(status)
if (status.import_status === 'completed' || status.import_status === 'failed') {
clearInterval(interval)
setPollingInterval(null)
setLoading(false)
}
} catch (err) {
console.error('Polling error:', err)
}
}, 2000)
setPollingInterval(interval)
} catch (err) {
setError(err.message)
setLoading(false)
}
}
return (
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
<h1>MediaWiki Import (Semantic MediaWiki)</h1>
<p style={{ color: 'var(--text2)', marginBottom: '24px' }}>
Importiere Übungen, Fähigkeiten und Methoden aus karatetrainer.net
</p>
{/* Tabs */}
<div style={{ borderBottom: '2px solid var(--border)', marginBottom: '24px' }}>
<div style={{ display: 'flex', gap: '8px' }}>
{['preview', 'execute', 'history'].map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
padding: '12px 24px',
background: activeTab === tab ? 'var(--accent)' : 'transparent',
color: activeTab === tab ? 'white' : 'var(--text1)',
border: 'none',
borderBottom: activeTab === tab ? '2px solid var(--accent)' : '2px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === tab ? 'bold' : 'normal',
transition: 'all 0.2s'
}}
>
{tab === 'preview' && '👁️ Vorschau'}
{tab === 'execute' && '▶️ Ausführen'}
{tab === 'history' && '📜 Historie'}
</button>
))}
</div>
</div>
{/* Error Display */}
{error && (
<div style={{
padding: '16px',
background: '#FEE',
border: '1px solid #C00',
borderRadius: '8px',
marginBottom: '20px'
}}>
<strong>Fehler:</strong> {error}
</div>
)}
{/* Preview Tab */}
{activeTab === 'preview' && (
<div>
<div style={{ background: 'var(--surface)', padding: '20px', borderRadius: '12px', marginBottom: '20px' }}>
<h2>Import-Vorschau</h2>
<div style={{ display: 'grid', gap: '16px', marginTop: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
Kategorie
</label>
<input
type="text"
value={previewCategory}
onChange={(e) => setPreviewCategory(e.target.value)}
placeholder="Übungen"
style={{
width: '100%',
padding: '12px',
fontSize: '16px',
border: '1px solid var(--border)',
borderRadius: '8px'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
Import-Typ
</label>
<select
value={previewType}
onChange={(e) => setPreviewType(e.target.value)}
style={{
width: '100%',
padding: '12px',
fontSize: '16px',
border: '1px solid var(--border)',
borderRadius: '8px'
}}
>
<option value="exercise">Übungen</option>
<option value="skill">Fähigkeiten</option>
<option value="method">Methoden</option>
</select>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
Limit
</label>
<input
type="number"
value={previewLimit}
onChange={(e) => setPreviewLimit(parseInt(e.target.value) || 10)}
min="1"
max="100"
style={{
width: '100%',
padding: '12px',
fontSize: '16px',
border: '1px solid var(--border)',
borderRadius: '8px'
}}
/>
</div>
<button
onClick={handlePreview}
disabled={loading}
style={{
padding: '16px',
background: 'var(--accent)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '16px',
fontWeight: 'bold',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1
}}
>
{loading ? 'Lade...' : '👁️ Vorschau laden'}
</button>
</div>
</div>
{/* Preview Results */}
{previewData && (
<div>
<h3>
{previewData.total_found} Einträge gefunden in Kategorie "{previewData.category}"
</h3>
<div style={{ marginTop: '16px', display: 'grid', gap: '12px' }}>
{previewData.preview.map((item, idx) => (
<details
key={idx}
style={{
background: 'var(--surface)',
padding: '16px',
borderRadius: '8px',
border: `2px solid ${item.errors.length > 0 ? '#C00' : item.warnings.length > 0 ? '#F90' : 'var(--border)'}`
}}
>
<summary style={{ cursor: 'pointer', fontWeight: 'bold', fontSize: '16px' }}>
{item.already_imported && '✅ '}
{item.wiki_page_title}
{item.errors.length > 0 && ' ❌'}
{item.warnings.length > 0 && ' ⚠️'}
</summary>
<div style={{ marginTop: '12px', fontSize: '14px', color: 'var(--text2)' }}>
{item.already_imported && (
<p><strong>Bereits importiert:</strong> {new Date(item.last_imported_at).toLocaleString('de-DE')}</p>
)}
{item.warnings.length > 0 && (
<div style={{ background: '#FFC', padding: '8px', borderRadius: '4px', marginTop: '8px' }}>
<strong>Warnungen:</strong>
<ul>{item.warnings.map((w, i) => <li key={i}>{w}</li>)}</ul>
</div>
)}
{item.errors.length > 0 && (
<div style={{ background: '#FEE', padding: '8px', borderRadius: '4px', marginTop: '8px' }}>
<strong>Fehler:</strong>
<ul>{item.errors.map((e, i) => <li key={i}>{e}</li>)}</ul>
</div>
)}
<details style={{ marginTop: '8px' }}>
<summary style={{ cursor: 'pointer', color: 'var(--accent)' }}>
Gemappte Felder anzeigen
</summary>
<pre style={{
background: 'var(--bg)',
padding: '12px',
borderRadius: '4px',
overflow: 'auto',
fontSize: '12px',
marginTop: '8px'
}}>
{JSON.stringify(item.mapped_fields, null, 2)}
</pre>
</details>
</div>
</details>
))}
</div>
</div>
)}
</div>
)}
{/* Execute Tab */}
{activeTab === 'execute' && (
<div>
<div style={{ background: 'var(--surface)', padding: '20px', borderRadius: '12px', marginBottom: '20px' }}>
<h2>Import ausführen</h2>
<div style={{ display: 'grid', gap: '16px', marginTop: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
Kategorie
</label>
<input
type="text"
value={executeCategory}
onChange={(e) => setExecuteCategory(e.target.value)}
placeholder="Übungen"
style={{
width: '100%',
padding: '12px',
fontSize: '16px',
border: '1px solid var(--border)',
borderRadius: '8px'
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
Import-Typ
</label>
<select
value={executeType}
onChange={(e) => setExecuteType(e.target.value)}
style={{
width: '100%',
padding: '12px',
fontSize: '16px',
border: '1px solid var(--border)',
borderRadius: '8px'
}}
>
<option value="exercise">Übungen</option>
<option value="skill">Fähigkeiten</option>
<option value="method">Methoden</option>
</select>
</div>
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={executeReimport}
onChange={(e) => setExecuteReimport(e.target.checked)}
/>
<span>Existierende überschreiben (Reimport)</span>
</label>
</div>
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={executeDryRun}
onChange={(e) => setExecuteDryRun(e.target.checked)}
/>
<span>Dry-Run (nicht speichern)</span>
</label>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
Limit (optional)
</label>
<input
type="number"
value={executeLimit || ''}
onChange={(e) => setExecuteLimit(e.target.value ? parseInt(e.target.value) : null)}
placeholder="Kein Limit"
min="1"
style={{
width: '100%',
padding: '12px',
fontSize: '16px',
border: '1px solid var(--border)',
borderRadius: '8px'
}}
/>
</div>
<button
onClick={handleExecute}
disabled={loading}
style={{
padding: '16px',
background: executeDryRun ? 'var(--accent-dark)' : 'var(--accent)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '16px',
fontWeight: 'bold',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1
}}
>
{loading ? '⏳ Import läuft...' : executeDryRun ? '🧪 Dry-Run starten' : '▶️ Import starten'}
</button>
</div>
</div>
{/* Import Status */}
{currentImport && (
<div style={{
background: 'var(--surface)',
padding: '20px',
borderRadius: '12px',
border: `2px solid ${
currentImport.import_status === 'completed' ? '#0A0' :
currentImport.import_status === 'failed' ? '#C00' :
'var(--accent)'
}`
}}>
<h3>
{currentImport.import_status === 'running' && '⏳ Import läuft...'}
{currentImport.import_status === 'completed' && '✅ Import abgeschlossen'}
{currentImport.import_status === 'failed' && '❌ Import fehlgeschlagen'}
</h3>
<div style={{ marginTop: '16px', display: 'grid', gap: '8px', fontSize: '14px' }}>
<div><strong>Log ID:</strong> {currentImport.id}</div>
<div><strong>Kategorie:</strong> {currentImport.category}</div>
<div><strong>Typ:</strong> {currentImport.import_type}</div>
<div><strong>Total:</strong> {currentImport.items_total}</div>
<div style={{ color: '#0A0' }}><strong> Importiert:</strong> {currentImport.items_imported}</div>
<div style={{ color: '#F90' }}><strong> Übersprungen:</strong> {currentImport.items_skipped}</div>
<div style={{ color: '#C00' }}><strong> Fehlgeschlagen:</strong> {currentImport.items_failed}</div>
{currentImport.started_at && (
<div><strong>Gestartet:</strong> {new Date(currentImport.started_at).toLocaleString('de-DE')}</div>
)}
{currentImport.finished_at && (
<div><strong>Beendet:</strong> {new Date(currentImport.finished_at).toLocaleString('de-DE')}</div>
)}
</div>
{currentImport.error_log && currentImport.error_log.length > 0 && (
<details style={{ marginTop: '16px' }}>
<summary style={{ cursor: 'pointer', color: '#C00', fontWeight: 'bold' }}>
Fehler anzeigen ({currentImport.error_log.length})
</summary>
<div style={{
marginTop: '8px',
background: '#FEE',
padding: '12px',
borderRadius: '4px',
maxHeight: '300px',
overflow: 'auto'
}}>
{currentImport.error_log.map((err, idx) => (
<div key={idx} style={{ marginBottom: '8px', paddingBottom: '8px', borderBottom: '1px solid #CCC' }}>
<strong>{err.item}:</strong> {err.error}
</div>
))}
</div>
</details>
)}
</div>
)}
</div>
)}
{/* History Tab */}
{activeTab === 'history' && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h2>Import-Historie</h2>
<button
onClick={loadLogs}
disabled={loading}
style={{
padding: '8px 16px',
background: 'var(--accent)',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1
}}
>
🔄 Aktualisieren
</button>
</div>
{logs.length === 0 && !loading && (
<p style={{ color: 'var(--text2)', textAlign: 'center', padding: '40px' }}>
Noch keine Imports durchgeführt
</p>
)}
<div style={{ display: 'grid', gap: '12px' }}>
{logs.map((log) => (
<div
key={log.id}
style={{
background: 'var(--surface)',
padding: '16px',
borderRadius: '8px',
border: `2px solid ${
log.import_status === 'completed' ? '#0A0' :
log.import_status === 'failed' ? '#C00' :
'var(--accent)'
}`
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div>
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
{log.import_status === 'completed' && '✅ '}
{log.import_status === 'failed' && '❌ '}
{log.import_status === 'running' && '⏳ '}
{log.category} ({log.import_type})
{log.dry_run && ' [Dry-Run]'}
</div>
<div style={{ fontSize: '14px', color: 'var(--text2)', display: 'grid', gap: '4px' }}>
<div>{new Date(log.started_at).toLocaleString('de-DE')}</div>
<div>
<span style={{ color: '#0A0' }}> {log.items_imported}</span>
{' | '}
<span style={{ color: '#F90' }}> {log.items_skipped}</span>
{' | '}
<span style={{ color: '#C00' }}> {log.items_failed}</span>
{' | '}
<span>Total: {log.items_total}</span>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@ -595,7 +595,26 @@ export const api = {
// System
getVersion,
healthCheck
healthCheck,
// MediaWiki Import
previewMediaWikiImport: (category, importType = 'exercise', limit = 10) =>
request(`/api/import/mediawiki/preview?category=${encodeURIComponent(category)}&import_type=${importType}&limit=${limit}`),
executeMediaWikiImport: (data) =>
request('/api/import/mediawiki/execute', {
method: 'POST',
body: JSON.stringify(data)
}),
getMediaWikiImportStatus: (logId) =>
request(`/api/import/mediawiki/status/${logId}`),
listMediaWikiImportLogs: () =>
request('/api/import/mediawiki/logs'),
deleteMediaWikiImportReference: (refId) =>
request(`/api/import/mediawiki/references/${refId}`, { method: 'DELETE' })
}
export default api