2026-05-22 11:02:02 +02:00
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
|
|
|
|
import { Navigate } from 'react-router-dom'
|
|
|
|
|
|
import { Sparkles } from 'lucide-react'
|
|
|
|
|
|
import { useAuth } from '../context/AuthContext'
|
|
|
|
|
|
import api from '../utils/api'
|
|
|
|
|
|
import AdminPageNav from '../components/AdminPageNav'
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Pflege von ai_prompts (OpenRouter-Vorlagen) — nur Superadmin.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export default function AdminAiPromptsPage() {
|
|
|
|
|
|
const { user } = useAuth()
|
|
|
|
|
|
const isSuperadmin = user?.role === 'superadmin'
|
|
|
|
|
|
|
|
|
|
|
|
const [prompts, setPrompts] = useState([])
|
|
|
|
|
|
const [catalog, setCatalog] = useState(null)
|
|
|
|
|
|
const [selectedId, setSelectedId] = useState(null)
|
|
|
|
|
|
const [detail, setDetail] = useState(null)
|
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
|
|
const [error, setError] = useState('')
|
|
|
|
|
|
|
|
|
|
|
|
const [draftName, setDraftName] = useState('')
|
|
|
|
|
|
const [draftDesc, setDraftDesc] = useState('')
|
|
|
|
|
|
const [draftTemplate, setDraftTemplate] = useState('')
|
2026-05-22 12:37:43 +02:00
|
|
|
|
const [draftOpenrouterModel, setDraftOpenrouterModel] = useState('')
|
2026-05-22 11:02:02 +02:00
|
|
|
|
const [draftActive, setDraftActive] = useState(true)
|
|
|
|
|
|
|
|
|
|
|
|
const [pvTitle, setPvTitle] = useState('Testübung')
|
|
|
|
|
|
const [pvGoal, setPvGoal] = useState('<p>Ziel hier</p>')
|
|
|
|
|
|
const [pvExec, setPvExec] = useState('<p>Ablauf hier</p>')
|
|
|
|
|
|
const [pvHint, setPvHint] = useState('')
|
|
|
|
|
|
const [pvFocusId, setPvFocusId] = useState('')
|
2026-06-15 07:50:49 +02:00
|
|
|
|
const [pvGoalQuery, setPvGoalQuery] = useState(
|
|
|
|
|
|
'Mae Geri vom Grundschritt bis zur kontrollierten Kumite-Nähe'
|
|
|
|
|
|
)
|
|
|
|
|
|
const [pvUserNotes, setPvUserNotes] = useState('Fokus Breitensport, ohne Wettkampfdruck.')
|
|
|
|
|
|
const [pvMaxSteps, setPvMaxSteps] = useState('5')
|
|
|
|
|
|
const [pvSearchQuery, setPvSearchQuery] = useState('')
|
2026-05-22 11:02:02 +02:00
|
|
|
|
const [pvPreview, setPvPreview] = useState(null)
|
|
|
|
|
|
|
2026-06-15 07:50:49 +02:00
|
|
|
|
const selectedSlug = (detail?.slug || '').trim().toLowerCase()
|
|
|
|
|
|
const isExercisePreviewSlug = [
|
|
|
|
|
|
'exercise_summary',
|
|
|
|
|
|
'exercise_skill_suggestions',
|
|
|
|
|
|
'exercise_instruction_rewrite',
|
|
|
|
|
|
].includes(selectedSlug)
|
|
|
|
|
|
const isPlanningPreviewSlug = selectedSlug.startsWith('planning_')
|
|
|
|
|
|
|
2026-05-22 11:02:02 +02:00
|
|
|
|
const loadList = useCallback(async () => {
|
|
|
|
|
|
const [pList, cat] = await Promise.all([
|
|
|
|
|
|
api.listAdminAiPrompts(),
|
|
|
|
|
|
api.getAdminAiPromptPlaceholdersCatalog(),
|
|
|
|
|
|
])
|
|
|
|
|
|
setPrompts(Array.isArray(pList) ? pList : [])
|
|
|
|
|
|
setCatalog(cat || null)
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!isSuperadmin) return
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
;(async () => {
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
try {
|
|
|
|
|
|
await loadList()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (!cancelled) setError(e.message || String(e))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (!cancelled) setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
})()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [isSuperadmin, loadList])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!isSuperadmin || !selectedId) {
|
|
|
|
|
|
setDetail(null)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
;(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const d = await api.getAdminAiPrompt(selectedId)
|
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
|
setDetail(d)
|
|
|
|
|
|
setDraftName(d.display_name || '')
|
|
|
|
|
|
setDraftDesc(d.description || '')
|
|
|
|
|
|
setDraftTemplate(d.template || '')
|
2026-05-22 12:37:43 +02:00
|
|
|
|
setDraftOpenrouterModel(
|
|
|
|
|
|
typeof d.openrouter_model === 'string' ? d.openrouter_model : ''
|
|
|
|
|
|
)
|
2026-05-22 11:02:02 +02:00
|
|
|
|
setDraftActive(!!d.active)
|
|
|
|
|
|
setPvPreview(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (!cancelled) setError(e.message || String(e))
|
|
|
|
|
|
}
|
|
|
|
|
|
})()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [isSuperadmin, selectedId])
|
|
|
|
|
|
|
|
|
|
|
|
const save = async () => {
|
|
|
|
|
|
if (!detail?.id) return
|
|
|
|
|
|
setSaving(true)
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.updateAdminAiPrompt(detail.id, {
|
|
|
|
|
|
template: draftTemplate,
|
|
|
|
|
|
display_name: draftName,
|
|
|
|
|
|
description: draftDesc,
|
|
|
|
|
|
active: draftActive,
|
2026-05-22 12:37:43 +02:00
|
|
|
|
openrouter_model: draftOpenrouterModel.trim(),
|
2026-05-22 11:02:02 +02:00
|
|
|
|
})
|
|
|
|
|
|
await loadList()
|
|
|
|
|
|
const nd = await api.getAdminAiPrompt(detail.id)
|
|
|
|
|
|
setDetail(nd)
|
|
|
|
|
|
setPvPreview(null)
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(e.message || String(e))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const resetTemplate = async () => {
|
|
|
|
|
|
if (!detail?.id || !detail.has_reference_template) return
|
|
|
|
|
|
if (!confirm('Template auf gespeicherten Referenztext zurücksetzen?')) return
|
|
|
|
|
|
setSaving(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const nd = await api.resetAdminAiPromptTemplate(detail.id)
|
|
|
|
|
|
setDetail(nd)
|
|
|
|
|
|
setDraftTemplate(nd.template || '')
|
|
|
|
|
|
await loadList()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(e.message || String(e))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const runPreview = async () => {
|
|
|
|
|
|
if (!detail?.id) return
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
try {
|
2026-06-15 07:50:49 +02:00
|
|
|
|
const body = {}
|
|
|
|
|
|
if (isPlanningPreviewSlug) {
|
|
|
|
|
|
body.goal_query = pvGoalQuery.trim() || undefined
|
|
|
|
|
|
body.user_notes = pvUserNotes.trim() || undefined
|
|
|
|
|
|
const ms = parseInt(String(pvMaxSteps).trim(), 10)
|
|
|
|
|
|
if (Number.isFinite(ms) && ms >= 2 && ms <= 10) body.max_steps = ms
|
|
|
|
|
|
const sq = pvSearchQuery.trim()
|
|
|
|
|
|
if (sq) body.search_query = sq
|
|
|
|
|
|
} else if (isExercisePreviewSlug) {
|
|
|
|
|
|
body.title = pvTitle
|
|
|
|
|
|
body.goal = pvGoal
|
|
|
|
|
|
body.execution = pvExec
|
|
|
|
|
|
body.focus_hint = pvHint || undefined
|
|
|
|
|
|
const fid = parseInt(String(pvFocusId).trim(), 10)
|
|
|
|
|
|
if (Number.isFinite(fid) && fid >= 1) {
|
|
|
|
|
|
body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }]
|
|
|
|
|
|
}
|
2026-05-22 11:02:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
const r = await api.previewAdminAiPrompt(detail.id, body)
|
|
|
|
|
|
setPvPreview(r)
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(e.message || String(e))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!isSuperadmin) return <Navigate to="/" replace />
|
|
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ padding: 16 }}>
|
|
|
|
|
|
<AdminPageNav />
|
|
|
|
|
|
<div className="card" style={{ marginTop: 16, padding: 24, textAlign: 'center' }}>
|
|
|
|
|
|
<div className="spinner" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '16px', paddingBottom: 96 }}>
|
|
|
|
|
|
<AdminPageNav />
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
|
|
|
|
|
<Sparkles size={22} />
|
|
|
|
|
|
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>KI Prompts</h1>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0 }}>
|
2026-06-15 07:50:49 +02:00
|
|
|
|
Datenbankvorlagen (<code>ai_prompts</code>) für Übungs- und Planungs-KI. Platzhalter im Mustache-Stil werden
|
|
|
|
|
|
serverseitig aufgelöst — die Vorschau unten ruft kein externes Modell auf.
|
2026-05-22 11:02:02 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
{error ? <p style={{ color: 'var(--danger)' }}>{error}</p> : null}
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '260px minmax(0,1fr)', gap: 16 }}>
|
|
|
|
|
|
<div className="card" style={{ padding: 12 }}>
|
|
|
|
|
|
<strong style={{ fontSize: '13px' }}>Prompts</strong>
|
|
|
|
|
|
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0 0', maxHeight: '70vh', overflow: 'auto' }}>
|
|
|
|
|
|
{(prompts || []).map((p) => (
|
|
|
|
|
|
<li key={p.id} style={{ marginBottom: 8 }}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`btn ${selectedId === p.id ? 'btn-primary' : 'btn-secondary'}`}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
justifyContent: 'flex-start',
|
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
|
padding: '8px 10px',
|
|
|
|
|
|
alignItems: 'flex-start',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
textAlign: 'left',
|
|
|
|
|
|
}}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setSelectedId(p.id)
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span style={{ fontWeight: 600 }}>{p.display_name}</span>
|
|
|
|
|
|
<span style={{ opacity: 0.85, marginTop: 2 }}>{p.slug}</span>
|
|
|
|
|
|
{!p.active ? (
|
|
|
|
|
|
<span style={{ color: 'var(--danger)', fontSize: 11 }}>
|
|
|
|
|
|
inaktiv
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
2026-05-22 12:37:43 +02:00
|
|
|
|
{p.openrouter_model ? (
|
|
|
|
|
|
<span style={{ fontSize: 11, color: 'var(--text3)' }} title="OpenRouter-Modell für diesen Prompt">
|
|
|
|
|
|
Model: <code>{p.openrouter_model}</code>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
2026-05-22 11:02:02 +02:00
|
|
|
|
{p.is_modified ? <span style={{ fontSize: 11 }}>(von Referenz abweichend)</span> : null}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{!selectedId ? (
|
|
|
|
|
|
<p style={{ color: 'var(--text3)' }}>Prompt links wählen.</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="card" style={{ padding: 16 }}>
|
|
|
|
|
|
<div style={{ marginBottom: 12 }}>
|
|
|
|
|
|
<div style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
|
|
|
|
|
slug: <code>{detail?.slug}</code> · Ausgabe:{' '}
|
|
|
|
|
|
<code>{detail?.output_format}</code> · Kategorie: <code>{detail?.category}</code>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row" style={{ marginTop: 10 }}>
|
|
|
|
|
|
<label className="form-label">Name</label>
|
|
|
|
|
|
<input className="form-input" value={draftName} onChange={(e) => setDraftName(e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Beschreibung</label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
rows={3}
|
|
|
|
|
|
value={draftDesc}
|
|
|
|
|
|
onChange={(e) => setDraftDesc(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-05-22 12:37:43 +02:00
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">OpenRouter-Modell (optional)</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
placeholder="Leer = Server-OPENROUTER_MODEL · z.B. anthropic/claude-3.5-haiku"
|
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
|
spellCheck={false}
|
|
|
|
|
|
value={draftOpenrouterModel}
|
|
|
|
|
|
onChange={(e) => setDraftOpenrouterModel(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-05-22 11:02:02 +02:00
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
|
|
|
|
|
<input type="checkbox" checked={draftActive} onChange={(e) => setDraftActive(e.target.checked)} />
|
|
|
|
|
|
Aktiv
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="form-label">Template (Mustache-Zeilen mit {'{{'}}name{'}'}})</label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
|
|
|
|
|
|
rows={22}
|
|
|
|
|
|
value={draftTemplate}
|
|
|
|
|
|
onChange={(e) => setDraftTemplate(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
|
|
|
|
|
<button type="button" className="btn btn-primary" disabled={saving} onClick={() => save()}>
|
|
|
|
|
|
Speichern
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
disabled={saving || !detail?.has_reference_template}
|
|
|
|
|
|
title={!detail?.has_reference_template ? 'Nach Migration 069 bzw. manuell default_template gesetzt' : ''}
|
|
|
|
|
|
onClick={() => resetTemplate()}
|
|
|
|
|
|
>
|
|
|
|
|
|
Auf Referenz zurücksetzen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<details style={{ marginTop: 20 }}>
|
|
|
|
|
|
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Platzhalter-Katalog (Übung)</summary>
|
|
|
|
|
|
{catalog?.placeholders ? (
|
|
|
|
|
|
<ul style={{ paddingLeft: 18, marginTop: 8 }}>
|
|
|
|
|
|
{catalog.placeholders.map((ph) => (
|
|
|
|
|
|
<li key={ph.key} style={{ marginBottom: 10, fontSize: 13 }}>
|
|
|
|
|
|
<code>{ph.placeholder}</code> — {ph.description}{' '}
|
|
|
|
|
|
<span style={{ color: 'var(--text3)' }}>
|
|
|
|
|
|
[{Array.isArray(ph.used_by_slugs) ? ph.used_by_slugs.join(', ') : ''}]
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p>Wird geladen …</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</details>
|
|
|
|
|
|
|
|
|
|
|
|
<section style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
|
|
|
|
|
<h4 style={{ margin: '0 0 12px', fontSize: '15px' }}>Vorschau (ohne OpenRouter)</h4>
|
2026-06-15 07:50:49 +02:00
|
|
|
|
{isPlanningPreviewSlug ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 0 }}>
|
|
|
|
|
|
Beispielkontext für Planungs-Prompts — echte Katalog-Auszüge aus der Datenbank, übrige Felder
|
|
|
|
|
|
sind repräsentative Demo-Daten.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Zielanfrage (goal_query)</label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
rows={3}
|
|
|
|
|
|
value={pvGoalQuery}
|
|
|
|
|
|
onChange={(e) => setPvGoalQuery(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Trainer-Notizen (user_notes)</label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
rows={2}
|
|
|
|
|
|
value={pvUserNotes}
|
|
|
|
|
|
onChange={(e) => setPvUserNotes(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">max_steps (Roadmap)</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={2}
|
|
|
|
|
|
max={10}
|
|
|
|
|
|
value={pvMaxSteps}
|
|
|
|
|
|
onChange={(e) => setPvMaxSteps(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Suchanfrage (optional)</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
placeholder="Leer = goal_query"
|
|
|
|
|
|
value={pvSearchQuery}
|
|
|
|
|
|
onChange={(e) => setPvSearchQuery(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : isExercisePreviewSlug ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Titel</label>
|
|
|
|
|
|
<input className="form-input" value={pvTitle} onChange={(e) => setPvTitle(e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Fokus-ID (optional, Retrieval‑Raster)</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
placeholder="numerisch"
|
|
|
|
|
|
value={pvFocusId}
|
|
|
|
|
|
onChange={(e) => setPvFocusId(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Fokus-Hinweistext</label>
|
|
|
|
|
|
<input className="form-input" value={pvHint} onChange={(e) => setPvHint(e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Ziel (HTML möglich)</label>
|
|
|
|
|
|
<textarea className="form-input" rows={4} value={pvGoal} onChange={(e) => setPvGoal(e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Durchführung (HTML möglich)</label>
|
|
|
|
|
|
<textarea className="form-input" rows={4} value={pvExec} onChange={(e) => setPvExec(e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p style={{ fontSize: 13, color: 'var(--text3)' }}>
|
|
|
|
|
|
Für diesen Slug ist noch kein Beispielkontext hinterlegt — es wird nur das Roh-Template ohne
|
|
|
|
|
|
Ersetzung angezeigt.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2026-05-22 11:02:02 +02:00
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => runPreview()}>
|
|
|
|
|
|
Platzhalter auflösen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{pvPreview?.warning ? (
|
|
|
|
|
|
<p style={{ marginTop: 10, color: 'var(--text3)', fontSize: 13 }}>{pvPreview.warning}</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{pvPreview?.placeholders_remaining?.length ? (
|
|
|
|
|
|
<p style={{ color: 'var(--danger)', fontSize: 13 }}>
|
|
|
|
|
|
Unbekannte Platzhalter im Ergebnis:{' '}
|
|
|
|
|
|
{pvPreview.placeholders_remaining.join(', ')}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{pvPreview?.resolved_template != null ? (
|
|
|
|
|
|
<pre
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginTop: 12,
|
|
|
|
|
|
padding: 12,
|
|
|
|
|
|
background: 'var(--surface2)',
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
|
whiteSpace: 'pre-wrap',
|
|
|
|
|
|
wordBreak: 'break-word',
|
|
|
|
|
|
maxHeight: 360,
|
|
|
|
|
|
overflow: 'auto',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{pvPreview.resolved_template}
|
|
|
|
|
|
</pre>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|